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