1 /*
2 * utils.cpp
3 * Copyright 2009-2010, Thorbjørn Lindeijer <thorbjorn@lindeijer.nl>
4 *
5 * This file is part of Tiled.
6 *
7 * This program is free software; you can redistribute it and/or modify it
8 * under the terms of the GNU General Public License as published by the Free
9 * Software Foundation; either version 2 of the License, or (at your option)
10 * any later version.
11 *
12 * This program is distributed in the hope that it will be useful, but WITHOUT
13 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
14 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
15 * more details.
16 *
17 * You should have received a copy of the GNU General Public License along with
18 * this program. If not, see <http://www.gnu.org/licenses/>.
19 */
20
21 #include "utils.h"
22
23 #include "mapformat.h"
24 #include "preferences.h"
25
26 #include <QAction>
27 #include <QApplication>
28 #include <QClipboard>
29 #ifdef TILED_ENABLE_DBUS
30 #include <QDBusConnection>
31 #include <QDBusMessage>
32 #endif
33 #include <QDesktopServices>
34 #include <QDir>
35 #include <QFileInfo>
36 #include <QGuiApplication>
37 #include <QImageReader>
38 #include <QImageWriter>
39 #include <QJsonDocument>
40 #include <QKeyEvent>
41 #include <QMainWindow>
42 #include <QMenu>
43 #include <QProcess>
44 #include <QRegularExpression>
45 #if QT_VERSION < QT_VERSION_CHECK(5,15,0)
46 #include <QRegExp>
47 #endif
48 #include <QScreen>
49
50 #include "qtcompat_p.h"
51
toImageFileFilter(const QList<QByteArray> & formats)52 static QString toImageFileFilter(const QList<QByteArray> &formats)
53 {
54 QString filter(QCoreApplication::translate("Utils", "Image files"));
55 filter += QStringLiteral(" (");
56 bool first = true;
57 for (const QByteArray &format : formats) {
58 if (!first)
59 filter += QLatin1Char(' ');
60 first = false;
61 filter += QStringLiteral("*.");
62 filter += QString::fromLatin1(format.toLower());
63 }
64 filter += QLatin1Char(')');
65 return filter;
66 }
67
68 namespace Tiled {
69 namespace Utils {
70
71 /**
72 * Returns a file dialog filter that matches all readable image formats.
73 *
74 * This includes all supported map formats, which are rendered to an image when
75 * used in this context.
76 */
readableImageFormatsFilter()77 QString readableImageFormatsFilter()
78 {
79 auto imageFilter = toImageFileFilter(QImageReader::supportedImageFormats());
80
81 FormatHelper<MapFormat> helper(FileFormat::Read, imageFilter);
82 return helper.filter();
83 }
84
85 /**
86 * Returns a file dialog filter that matches all writable image formats.
87 */
writableImageFormatsFilter()88 QString writableImageFormatsFilter()
89 {
90 return toImageFileFilter(QImageWriter::supportedImageFormats());
91 }
92
93 // Makes a list of filters from a normal filter string "Image Files (*.png *.jpg)"
94 //
95 // Copied from qplatformdialoghelper.cpp in Qt, used under the terms of the GPL
96 // version 2.0.
cleanFilterList(const QString & filter)97 QStringList cleanFilterList(const QString &filter)
98 {
99 const char filterRegExp[] =
100 "^(.*)\\(([a-zA-Z0-9_.,*? +;#\\-\\[\\]@\\{\\}/!<>\\$%&=^~:\\|]*)\\)$";
101
102 QRegularExpression regexp(QString::fromLatin1(filterRegExp));
103 Q_ASSERT(regexp.isValid());
104 QString f = filter;
105 QRegularExpressionMatch match = regexp.match(filter);
106 if (match.hasMatch())
107 f = match.captured(2);
108 #if QT_VERSION < QT_VERSION_CHECK(5, 14, 0)
109 return f.split(QLatin1Char(' '), QString::SkipEmptyParts);
110 #else
111 return f.split(QLatin1Char(' '), Qt::SkipEmptyParts);
112 #endif
113 }
114
115 /**
116 * Returns whether the \a filePath has an extension that is matched by
117 * the \a nameFilter.
118 */
fileNameMatchesNameFilter(const QString & filePath,const QString & nameFilter)119 bool fileNameMatchesNameFilter(const QString &filePath,
120 const QString &nameFilter)
121 {
122 #if QT_VERSION < QT_VERSION_CHECK(5,15,0)
123 QRegExp rx;
124 rx.setCaseSensitivity(Qt::CaseInsensitive);
125 rx.setPatternSyntax(QRegExp::Wildcard);
126 #else
127 QRegularExpression rx;
128 rx.setPatternOptions(QRegularExpression::CaseInsensitiveOption);
129 #endif
130
131 const QStringList filterList = cleanFilterList(nameFilter);
132 const QString fileName = QFileInfo(filePath).fileName();
133 for (const QString &filter : filterList) {
134 #if QT_VERSION < QT_VERSION_CHECK(5,15,0)
135 rx.setPattern(filter);
136 if (rx.exactMatch(fileName))
137 #else
138 rx.setPattern(QRegularExpression::wildcardToRegularExpression(filter));
139 if (rx.match(fileName).hasMatch())
140 #endif
141 return true;
142 }
143 return false;
144 }
145
firstExtension(const QString & nameFilter)146 QString firstExtension(const QString &nameFilter)
147 {
148 QString extension;
149
150 const auto filterList = cleanFilterList(nameFilter);
151 if (!filterList.isEmpty()) {
152 extension = filterList.first();
153 extension.remove(QLatin1Char('*'));
154 }
155
156 return extension;
157 }
158
159 struct Match {
160 int wordIndex;
161 int stringIndex;
162 };
163
164 /**
165 * Matches the given \a word against the \a string. The match is a fuzzy one,
166 * being case-insensitive and allowing any characters to appear between the
167 * characters of the given word.
168 *
169 * Attempts to make matching indexes sequential.
170 */
matchingIndexes(const QString & word,QStringRef string,QVarLengthArray<Match,16> & matchingIndexes)171 static bool matchingIndexes(const QString &word, QStringRef string, QVarLengthArray<Match, 16> &matchingIndexes)
172 {
173 int index = 0;
174
175 for (int i = 0; i < word.size(); ++i) {
176 const QChar c = word.at(i);
177
178 int newIndex = string.indexOf(c, index, Qt::CaseInsensitive);
179 if (newIndex == -1)
180 return false;
181
182 // If the new match is not sequential, check if we can make it
183 // sequential by moving a previous match forward
184 if (newIndex != index) {
185 for (int offset = 1; matchingIndexes.size() >= offset; ++offset) {
186 int backTrackIndex = newIndex - offset;
187 Match &match = matchingIndexes[matchingIndexes.size() - offset];
188
189 const int previousIndex = string.lastIndexOf(string.at(match.stringIndex), backTrackIndex, Qt::CaseInsensitive);
190
191 if (previousIndex == backTrackIndex)
192 match.stringIndex = previousIndex;
193 else
194 break;
195 }
196 }
197
198 matchingIndexes.append({ i, newIndex });
199 index = newIndex + 1;
200 }
201
202 return true;
203 }
204
205 /**
206 * Rates the match between \a word and \a string with a score indicating the
207 * strength of the match, for sorting purposes.
208 *
209 * A score of 0 indicates there is no match.
210 */
matchingScore(const QString & word,QStringRef string)211 static int matchingScore(const QString &word, QStringRef string)
212 {
213 QVarLengthArray<Match, 16> indexes;
214 if (!matchingIndexes(word, string, indexes))
215 return 0;
216
217 int score = 1; // empty word matches
218 int previousIndex = -1;
219
220 for (const Match &match : qAsConst(indexes)) {
221 const int start = match.stringIndex == 0;
222 const int sequential = match.stringIndex == previousIndex + 1;
223
224 const auto c = word.at(match.wordIndex);
225 const int caseMatch = c.isUpper() && string.at(match.stringIndex) == c;
226
227 score += 1 + start + sequential + caseMatch;
228 previousIndex = match.stringIndex;
229 }
230
231 return score;
232 }
233
matchingRanges(const QString & word,QStringRef string,int offset,RangeSet<int> & result)234 static bool matchingRanges(const QString &word, QStringRef string, int offset, RangeSet<int> &result)
235 {
236 QVarLengthArray<Match, 16> indexes;
237 if (!matchingIndexes(word, string, indexes))
238 return false;
239
240 for (const Match &match : qAsConst(indexes))
241 result.insert(match.stringIndex + offset);
242
243 return true;
244 }
245
matchingScore(const QStringList & words,QStringRef string)246 int matchingScore(const QStringList &words, QStringRef string)
247 {
248 const auto fileName = string.mid(string.lastIndexOf(QLatin1Char('/')) + 1);
249
250 int totalScore = 1; // no words matches everything
251
252 for (const QString &word : words) {
253 if (int score = Utils::matchingScore(word, fileName)) {
254 // Higher score if file name matches
255 totalScore += score * 2;
256 } else if ((score = Utils::matchingScore(word, string))) {
257 totalScore += score;
258 } else {
259 totalScore = 0;
260 break;
261 }
262 }
263
264 return totalScore;
265 }
266
matchingRanges(const QStringList & words,QStringRef string)267 RangeSet<int> matchingRanges(const QStringList &words, QStringRef string)
268 {
269 const int startOfFileName = string.lastIndexOf(QLatin1Char('/')) + 1;
270 const auto fileName = string.mid(startOfFileName);
271
272 RangeSet<int> result;
273
274 for (const QString &word : words) {
275 if (!matchingRanges(word, fileName, startOfFileName, result))
276 matchingRanges(word, string, 0, result);
277 }
278
279 return result;
280 }
281
282
283 /**
284 * Restores a widget's geometry.
285 * Requires the widget to have its object name set.
286 */
restoreGeometry(QWidget * widget)287 void restoreGeometry(QWidget *widget)
288 {
289 Q_ASSERT(!widget->objectName().isEmpty());
290
291 const auto preferences = Preferences::instance();
292
293 const QString key = widget->objectName() + QLatin1String("/Geometry");
294 widget->restoreGeometry(preferences->value(key).toByteArray());
295
296 if (QMainWindow *mainWindow = qobject_cast<QMainWindow*>(widget)) {
297 const QString stateKey = widget->objectName() + QLatin1String("/State");
298 mainWindow->restoreState(preferences->value(stateKey).toByteArray());
299 }
300 }
301
302 /**
303 * Saves a widget's geometry.
304 * Requires the widget to have its object name set.
305 */
saveGeometry(QWidget * widget)306 void saveGeometry(QWidget *widget)
307 {
308 Q_ASSERT(!widget->objectName().isEmpty());
309
310 auto preferences = Preferences::instance();
311
312 const QString key = widget->objectName() + QLatin1String("/Geometry");
313 preferences->setValue(key, widget->saveGeometry());
314
315 if (QMainWindow *mainWindow = qobject_cast<QMainWindow*>(widget)) {
316 const QString stateKey = widget->objectName() + QLatin1String("/State");
317 preferences->setValue(stateKey, mainWindow->saveState());
318 }
319 }
320
defaultDpi()321 int defaultDpi()
322 {
323 static int dpi = []{
324 if (const QScreen *screen = QGuiApplication::primaryScreen())
325 return static_cast<int>(screen->logicalDotsPerInchX());
326 #ifdef Q_OS_MAC
327 return 72;
328 #else
329 return 96;
330 #endif
331 }();
332 return dpi;
333 }
334
defaultDpiScale()335 qreal defaultDpiScale()
336 {
337 static qreal scale = []{
338 if (const QScreen *screen = QGuiApplication::primaryScreen())
339 return screen->logicalDotsPerInchX() / 96.0;
340 return 1.0;
341 }();
342 return scale;
343 }
344
dpiScaled(qreal value)345 qreal dpiScaled(qreal value)
346 {
347 #ifdef Q_OS_MAC
348 // On mac the DPI is always 72 so we should not scale it
349 return value;
350 #else
351 static const qreal scale = defaultDpiScale();
352 return value * scale;
353 #endif
354 }
355
dpiScaled(int value)356 int dpiScaled(int value)
357 {
358 return qRound(dpiScaled(qreal(value)));
359 }
360
dpiScaled(QSize value)361 QSize dpiScaled(QSize value)
362 {
363 return QSize(dpiScaled(value.width()),
364 dpiScaled(value.height()));
365 }
366
dpiScaled(QPoint value)367 QPoint dpiScaled(QPoint value)
368 {
369 return QPoint(dpiScaled(value.x()),
370 dpiScaled(value.y()));
371 }
372
dpiScaled(QRectF value)373 QRectF dpiScaled(QRectF value)
374 {
375 return QRectF(dpiScaled(value.x()),
376 dpiScaled(value.y()),
377 dpiScaled(value.width()),
378 dpiScaled(value.height()));
379 }
380
smallIconSize()381 QSize smallIconSize()
382 {
383 static QSize size = dpiScaled(QSize(16, 16));
384 return size;
385 }
386
isZoomInShortcut(QKeyEvent * event)387 bool isZoomInShortcut(QKeyEvent *event)
388 {
389 if (event->matches(QKeySequence::ZoomIn))
390 return true;
391 if (event->key() == Qt::Key_Plus)
392 return true;
393 if (event->key() == Qt::Key_Equal)
394 return true;
395
396 return false;
397 }
398
isZoomOutShortcut(QKeyEvent * event)399 bool isZoomOutShortcut(QKeyEvent *event)
400 {
401 if (event->matches(QKeySequence::ZoomOut))
402 return true;
403 if (event->key() == Qt::Key_Minus)
404 return true;
405 if (event->key() == Qt::Key_Underscore)
406 return true;
407
408 return false;
409 }
410
isResetZoomShortcut(QKeyEvent * event)411 bool isResetZoomShortcut(QKeyEvent *event)
412 {
413 if (event->key() == Qt::Key_0 && event->modifiers() & Qt::ControlModifier)
414 return true;
415
416 return false;
417 }
418
419 /*
420 * Code based on FileUtils::showInGraphicalShell from Qt Creator
421 * Copyright (C) 2016 The Qt Company Ltd.
422 * Used under the terms of the GNU General Public License version 3
423 */
showInFileManager(const QString & fileName)424 static void showInFileManager(const QString &fileName)
425 {
426 // Mac, Windows support folder or file.
427 #if defined(Q_OS_WIN)
428 QStringList param;
429 if (!QFileInfo(fileName).isDir())
430 param += QStringLiteral("/select,");
431 param += QDir::toNativeSeparators(fileName);
432 QProcess::startDetached(QLatin1String("explorer.exe"), param);
433 #elif defined(Q_OS_MAC)
434 QStringList scriptArgs;
435 scriptArgs << QLatin1String("-e")
436 << QStringLiteral("tell application \"Finder\" to reveal POSIX file \"%1\"")
437 .arg(fileName);
438 QProcess::execute(QLatin1String("/usr/bin/osascript"), scriptArgs);
439 scriptArgs.clear();
440 scriptArgs << QLatin1String("-e")
441 << QLatin1String("tell application \"Finder\" to activate");
442 QProcess::execute(QLatin1String("/usr/bin/osascript"), scriptArgs);
443 #else
444
445 #ifdef TILED_ENABLE_DBUS
446 QDBusMessage message = QDBusMessage::createMethodCall(
447 QStringLiteral("org.freedesktop.FileManager1"),
448 QStringLiteral("/org/freedesktop/FileManager1"),
449 QStringLiteral("org.freedesktop.FileManager1"),
450 QStringLiteral("ShowItems"));
451
452 message.setArguments({
453 QStringList(QUrl::fromLocalFile(fileName).toString()),
454 QString()
455 });
456
457 const QDBusError error = QDBusConnection::sessionBus().call(message);
458
459 if (!error.isValid())
460 return;
461 #endif // TILED_ENABLE_DBUS
462
463 // Fall back to xdg-open. We cannot select a file here, because
464 // xdg-open would open the file instead of the file browser...
465 QProcess::startDetached(QStringLiteral("xdg-open"),
466 QStringList(QFileInfo(fileName).absolutePath()));
467
468 #endif // !Q_OS_WIN && !Q_OS_MAC
469 }
470
addFileManagerActions(QMenu & menu,const QString & fileName)471 void addFileManagerActions(QMenu &menu, const QString &fileName)
472 {
473 if (fileName.isEmpty())
474 return;
475
476 menu.addAction(QCoreApplication::translate("Utils", "Copy File Path"), [fileName] {
477 QApplication::clipboard()->setText(QDir::toNativeSeparators(fileName));
478 });
479
480 addOpenContainingFolderAction(menu, fileName);
481 }
482
addOpenContainingFolderAction(QMenu & menu,const QString & fileName)483 void addOpenContainingFolderAction(QMenu &menu, const QString &fileName)
484 {
485 menu.addAction(QCoreApplication::translate("Utils", "Open Containing Folder..."), [fileName] {
486 showInFileManager(fileName);
487 });
488 }
489
addOpenWithSystemEditorAction(QMenu & menu,const QString & fileName)490 void addOpenWithSystemEditorAction(QMenu &menu, const QString &fileName)
491 {
492 menu.addAction(QCoreApplication::translate("Utils", "Open with System Editor"), [=] {
493 QDesktopServices::openUrl(QUrl::fromLocalFile(fileName));
494 });
495 }
496
readJsonFile(QIODevice & device,QSettings::SettingsMap & map)497 static bool readJsonFile(QIODevice &device, QSettings::SettingsMap &map)
498 {
499 QJsonParseError error;
500 map = QJsonDocument::fromJson(device.readAll(), &error).toVariant().toMap();
501 return error.error == QJsonParseError::NoError;
502 }
503
writeJsonFile(QIODevice & device,const QSettings::SettingsMap & map)504 static bool writeJsonFile(QIODevice &device, const QSettings::SettingsMap &map)
505 {
506 const auto json = QJsonDocument { QJsonObject::fromVariantMap(map) }.toJson();
507 return device.write(json) == json.size();
508 }
509
jsonSettingsFormat()510 QSettings::Format jsonSettingsFormat()
511 {
512 static const auto format = QSettings::registerFormat(QStringLiteral("json"),
513 readJsonFile,
514 writeJsonFile);
515 return format;
516 }
517
jsonSettings(const QString & fileName)518 std::unique_ptr<QSettings> jsonSettings(const QString &fileName)
519 {
520 return std::make_unique<QSettings>(fileName, jsonSettingsFormat());
521 }
522
523 } // namespace Utils
524 } // namespace Tiled
525