1 /*
2  * shortcutsettingspage.cpp
3  * Copyright 2019, Thorbjørn Lindeijer <bjorn@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 "shortcutsettingspage.h"
22 #include "ui_shortcutsettingspage.h"
23 
24 #include "actionmanager.h"
25 #include "savefile.h"
26 #include "utils.h"
27 
28 #include <QAbstractListModel>
29 #include <QAction>
30 #include <QApplication>
31 #include <QCoreApplication>
32 #include <QDateTime>
33 #include <QFileDialog>
34 #include <QItemEditorFactory>
35 #include <QKeyEvent>
36 #include <QKeySequenceEdit>
37 #include <QMessageBox>
38 #include <QSortFilterProxyModel>
39 #include <QStyledItemDelegate>
40 #include <QToolButton>
41 #include <QXmlStreamReader>
42 #include <QXmlStreamWriter>
43 
44 #include <memory>
45 
46 #include "qtcompat_p.h"
47 
48 namespace Tiled {
49 
50 /**
51  * The ActionsModel makes the list of actions and their shortcuts available
52  * to the view.
53  */
54 class ActionsModel : public QAbstractListModel
55 {
56     Q_OBJECT
57 
58 public:
59     enum UserRoles {
60         HasCustomShortcut = Qt::UserRole,
61         HasConflictingShortcut,
62         ActionId,
63     };
64 
65     explicit ActionsModel(QObject *parent = nullptr);
66 
67     void setVisible(bool visible);
68 
69     int rowCount(const QModelIndex &parent) const override;
70     int columnCount(const QModelIndex &parent) const override;
71     QVariant data(const QModelIndex &index, int role) const override;
72     bool setData(const QModelIndex &index, const QVariant &value, int role) override;
73     Qt::ItemFlags flags(const QModelIndex &index) const override;
74     QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
75 
76     void refresh();
77 
78 private:
79     void refreshConflicts();
80     void emitDataChanged(int row);
81     void actionChanged(Id actionId);
82 
83     QList<Id> mActions = ActionManager::actions();
84     QVector<bool> mConflicts;
85     bool mDirty = false;
86     bool mVisible = false;
87     bool mConflictsDirty = true;
88 };
89 
ActionsModel(QObject * parent)90 ActionsModel::ActionsModel(QObject *parent)
91     : QAbstractListModel(parent)
92 {
93     connect(ActionManager::instance(), &ActionManager::actionChanged,
94             this, &ActionsModel::actionChanged);
95     connect(ActionManager::instance(), &ActionManager::actionsChanged,
96             this, [this] { mDirty = mConflictsDirty = true; refresh(); });
97 
98     refreshConflicts();
99 }
100 
setVisible(bool visible)101 void ActionsModel::setVisible(bool visible)
102 {
103     mVisible = visible;
104     refresh();
105 }
106 
refresh()107 void ActionsModel::refresh()
108 {
109     if (!mVisible)
110         return;
111 
112     if (mDirty) {
113         beginResetModel();
114         mActions = ActionManager::actions();
115         refreshConflicts();
116         mDirty = false;
117         endResetModel();
118     } else {
119         refreshConflicts();
120     }
121 }
122 
refreshConflicts()123 void ActionsModel::refreshConflicts()
124 {
125     if (!mConflictsDirty)
126         return;
127 
128     QMultiMap<QKeySequence, Id> actionsByKey;
129 
130     for (const auto &actionId : qAsConst(mActions)) {
131         if (auto action = ActionManager::findAction(actionId))
132             if (!action->shortcut().isEmpty())
133                 actionsByKey.insert(action->shortcut(), actionId);
134     }
135 
136     QVector<bool> conflicts;
137     conflicts.reserve(mActions.size());
138 
139     for (const auto &actionId : qAsConst(mActions)) {
140         if (auto action = ActionManager::findAction(actionId))
141             conflicts.append(actionsByKey.count(action->shortcut()) > 1);
142         else
143             conflicts.append(false);
144     }
145 
146     mConflicts.swap(conflicts);
147     mConflictsDirty = false;
148 
149     if (!mDirty && conflicts.size() == mConflicts.size() && conflicts != mConflicts) {
150         emit dataChanged(index(0, 0),
151                          index(conflicts.size() - 1, 2),
152                          QVector<int> { Qt::ForegroundRole });
153     }
154 }
155 
rowCount(const QModelIndex & parent) const156 int ActionsModel::rowCount(const QModelIndex &parent) const
157 {
158     return parent.isValid() ? 0 : mActions.size();
159 }
160 
columnCount(const QModelIndex & parent) const161 int ActionsModel::columnCount(const QModelIndex &parent) const
162 {
163     return parent.isValid() ? 0 : 3;
164 }
165 
strippedText(QString s)166 static QString strippedText(QString s)
167 {
168     s.remove(QLatin1String("..."));
169     for (int i = 0; i < s.size(); ++i) {
170         if (s.at(i) == QLatin1Char('&'))
171             s.remove(i, 1);
172     }
173     return s.trimmed();
174 }
175 
data(const QModelIndex & index,int role) const176 QVariant ActionsModel::data(const QModelIndex &index, int role) const
177 {
178     switch (role) {
179     case Qt::DisplayRole: {
180         const Id actionId = mActions.at(index.row());
181 
182         switch (index.column()) {
183         case 0:
184             return actionId.name();
185         case 1:
186             return strippedText(ActionManager::action(actionId)->text());
187         case 2:
188             return ActionManager::action(actionId)->shortcut().toString(QKeySequence::NativeText);
189         }
190 
191         break;
192     }
193 
194     case Qt::EditRole: {
195         const Id actionId = mActions.at(index.row());
196         return ActionManager::action(actionId)->shortcut();
197     }
198     case Qt::FontRole: {
199         const Id actionId = mActions.at(index.row());
200 
201         if (ActionManager::instance()->hasCustomShortcut(actionId)) {
202             QFont font = QApplication::font();
203             font.setBold(true);
204             return font;
205         }
206         break;
207     }
208     case Qt::ForegroundRole: {
209         if (mConflicts.at(index.row()))
210             return QColor(Qt::red);
211         break;
212     }
213     case HasCustomShortcut: {
214         const Id actionId = mActions.at(index.row());
215         return ActionManager::instance()->hasCustomShortcut(actionId);
216     }
217     case HasConflictingShortcut:
218         return mConflicts.at(index.row());
219     case ActionId:
220         return QVariant::fromValue(mActions.at(index.row()));
221     }
222 
223     return QVariant();
224 }
225 
setData(const QModelIndex & index,const QVariant & value,int role)226 bool ActionsModel::setData(const QModelIndex &index, const QVariant &value, int role)
227 {
228     if (index.column() == 2 && role == Qt::EditRole) {
229         const Id actionId = mActions.at(index.row());
230         const auto action = ActionManager::findAction(actionId);
231 
232         if (action) {
233             auto actionManager = ActionManager::instance();
234 
235             // Null QVariant used for resetting shortcut to default
236             if (value.isNull() && actionManager->hasCustomShortcut(actionId)) {
237                 actionManager->resetCustomShortcut(actionId);
238                 emitDataChanged(index.row());
239                 refreshConflicts();
240                 return true;
241             }
242 
243             const auto keySequence = value.value<QKeySequence>();
244             if (action->shortcut() != keySequence) {
245                 // Guaranteed to trigger actionChanged, which emits dataChanged
246                 actionManager->setCustomShortcut(actionId, keySequence);
247                 refreshConflicts();
248                 return true;
249             }
250         }
251     }
252 
253     return false;
254 }
255 
flags(const QModelIndex & index) const256 Qt::ItemFlags ActionsModel::flags(const QModelIndex &index) const
257 {
258     auto f = QAbstractListModel::flags(index);
259 
260     if (index.column() == 2)
261         f |= Qt::ItemIsEditable;
262 
263     return f;
264 }
265 
headerData(int section,Qt::Orientation orientation,int role) const266 QVariant ActionsModel::headerData(int section, Qt::Orientation orientation, int role) const
267 {
268     if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
269         switch (section) {
270         case 0:
271             return tr("Action");
272         case 1:
273             return tr("Text");
274         case 2:
275             return tr("Shortcut");
276         }
277     }
278     return QVariant();
279 }
280 
emitDataChanged(int row)281 void ActionsModel::emitDataChanged(int row)
282 {
283     emit dataChanged(index(row, 0),
284                      index(row, 2),
285                      QVector<int> { Qt::DisplayRole, Qt::EditRole, Qt::FontRole });
286 }
287 
actionChanged(Id actionId)288 void ActionsModel::actionChanged(Id actionId)
289 {
290     int row = mActions.indexOf(actionId);
291     if (row != -1) {
292         mConflictsDirty = true;
293         emitDataChanged(row);
294     }
295 }
296 
297 
298 /**
299  * Special sort-filter model that is able to filter based on key sequences.
300  */
301 class KeySequenceFilterModel : public QSortFilterProxyModel
302 {
303 public:
KeySequenceFilterModel(QObject * parent=nullptr)304     KeySequenceFilterModel(QObject *parent = nullptr)
305         : QSortFilterProxyModel(parent)
306     {}
307 
308     void setFilter(const QString &pattern);
309 
310 protected:
311     bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
312 
313 private:
314     QString mPattern;
315     QKeySequence mKeySequence;
316 };
317 
setFilter(const QString & pattern)318 void KeySequenceFilterModel::setFilter(const QString &pattern)
319 {
320     mPattern = pattern;
321 
322     if (pattern.startsWith(QLatin1String("key:")))
323         mKeySequence = QKeySequence(pattern.mid(4));
324     else
325         mKeySequence = QKeySequence();
326 
327     setFilterFixedString(pattern);
328 }
329 
filterAcceptsRow(int sourceRow,const QModelIndex & sourceParent) const330 bool KeySequenceFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
331 {
332     if (mKeySequence.isEmpty())
333         return QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent);
334 
335     auto source = sourceModel();
336     auto keySequence = source->data(source->index(sourceRow, 2, sourceParent), Qt::EditRole).value<QKeySequence>();
337     return !keySequence.isEmpty() && mKeySequence.matches(keySequence) != QKeySequence::NoMatch;
338 }
339 
340 
341 /**
342  * ShortcutEditor is needed to add a "Clear" button to the QKeySequenceEdit,
343  * which doesn't feature one as of Qt 5.13. It also adds a button to reset
344  * the shortcut.
345  */
346 class ShortcutEditor : public QWidget
347 {
348     Q_OBJECT
349     Q_PROPERTY(QKeySequence keySequence READ keySequence WRITE setKeySequence NOTIFY keySequenceChanged USER true)
350 
351 public:
352     ShortcutEditor(QWidget *parent = nullptr);
353 
354     QKeySequence keySequence() const;
355 
356     void setResetEnabled(bool enabled);
357 
358 public slots:
359     void setKeySequence(QKeySequence keySequence);
360 
361 signals:
362     void resetRequested();
363     void editingFinished();
364     void keySequenceChanged(QKeySequence keySequence);
365 
366 private:
367     QKeySequenceEdit *mKeySequenceEdit;
368     QToolButton *mResetButton;
369 };
370 
ShortcutEditor(QWidget * parent)371 ShortcutEditor::ShortcutEditor(QWidget *parent)
372     : QWidget(parent)
373     , mKeySequenceEdit(new QKeySequenceEdit)
374 {
375     auto clearButton = new QToolButton(this);
376     clearButton->setAutoRaise(true);
377     clearButton->setAutoFillBackground(true);
378     clearButton->setToolTip(tr("Remove shortcut"));
379     clearButton->setEnabled(false);
380     clearButton->setIcon(QIcon(QLatin1String("://images/scalable/edit-delete-symbolic.svg")));
381 
382     mResetButton = new QToolButton(this);
383     mResetButton->setAutoRaise(true);
384     mResetButton->setAutoFillBackground(true);
385     mResetButton->setToolTip(tr("Reset shortcut to default"));
386     mResetButton->setIcon(QIcon(QLatin1String("://images/scalable/edit-undo-symbolic.svg")));
387 
388     auto layout = new QHBoxLayout(this);
389     layout->setContentsMargins(0, 0, 0, 0);
390     layout->setSpacing(0);
391     layout->addWidget(mKeySequenceEdit);
392     layout->addWidget(clearButton);
393     layout->addWidget(mResetButton);
394 
395     setFocusProxy(mKeySequenceEdit);
396 
397     connect(clearButton, &QToolButton::clicked,
398             mKeySequenceEdit, &QKeySequenceEdit::clear);
399     connect(mResetButton, &QToolButton::clicked,
400             this, &ShortcutEditor::resetRequested);
401 
402     connect(mKeySequenceEdit, &QKeySequenceEdit::editingFinished,
403             this, &ShortcutEditor::editingFinished);
404     connect(mKeySequenceEdit, &QKeySequenceEdit::keySequenceChanged,
405             this, &ShortcutEditor::keySequenceChanged);
406     connect(mKeySequenceEdit, &QKeySequenceEdit::keySequenceChanged,
407             this, [=] { clearButton->setEnabled(!keySequence().isEmpty()); });
408 }
409 
keySequence() const410 QKeySequence ShortcutEditor::keySequence() const
411 {
412     return mKeySequenceEdit->keySequence();
413 }
414 
setResetEnabled(bool enabled)415 void ShortcutEditor::setResetEnabled(bool enabled)
416 {
417     mResetButton->setEnabled(enabled);
418 }
419 
setKeySequence(QKeySequence keySequence)420 void ShortcutEditor::setKeySequence(QKeySequence keySequence)
421 {
422     mKeySequenceEdit->setKeySequence(keySequence);
423 }
424 
425 
426 /**
427  * ShortcutDelegate subclass needed to make the editor close when it emits the
428  * "editingFinished" signal.
429  */
430 class ShortcutDelegate : public QStyledItemDelegate
431 {
432     Q_OBJECT
433 
434 public:
435     ShortcutDelegate(QObject *parent = nullptr);
436 
437     QWidget *createEditor(QWidget *parent,
438                           const QStyleOptionViewItem &option,
439                           const QModelIndex &index) const override;
440 
441 private:
442     std::unique_ptr<QItemEditorFactory> mItemEditorFactory;
443 
444 };
445 
ShortcutDelegate(QObject * parent)446 ShortcutDelegate::ShortcutDelegate(QObject *parent)
447     : QStyledItemDelegate(parent)
448     , mItemEditorFactory(new QItemEditorFactory)
449 {
450     mItemEditorFactory->registerEditor(QMetaType::QKeySequence,
451                                        new QStandardItemEditorCreator<ShortcutEditor>);
452 
453     setItemEditorFactory(mItemEditorFactory.get());
454 }
455 
createEditor(QWidget * parent,const QStyleOptionViewItem & option,const QModelIndex & index) const456 QWidget *ShortcutDelegate::createEditor(QWidget *parent,
457                                         const QStyleOptionViewItem &option,
458                                         const QModelIndex &index) const
459 {
460     auto editor = QStyledItemDelegate::createEditor(parent, option, index);
461 
462     if (auto shortcutEditor = qobject_cast<ShortcutEditor*>(editor)) {
463         shortcutEditor->setResetEnabled(index.data(ActionsModel::HasCustomShortcut).toBool());
464 
465         const QPersistentModelIndex persistentIndex(index);
466 
467         connect(shortcutEditor, &ShortcutEditor::keySequenceChanged, this, [=] {
468             emit const_cast<ShortcutDelegate*>(this)->commitData(editor);
469             shortcutEditor->setResetEnabled(index.data(ActionsModel::HasCustomShortcut).toBool());
470         });
471 
472         connect(shortcutEditor, &ShortcutEditor::editingFinished, this, [=] {
473             emit const_cast<ShortcutDelegate*>(this)->closeEditor(editor);
474         });
475 
476         connect(shortcutEditor, &ShortcutEditor::resetRequested, this, [=] {
477             auto model = const_cast<QAbstractItemModel*>(persistentIndex.model());
478             model->setData(persistentIndex, QVariant(), Qt::EditRole);
479             shortcutEditor->setKeySequence(index.data(Qt::EditRole).value<QKeySequence>());
480             shortcutEditor->setResetEnabled(index.data(ActionsModel::HasCustomShortcut).toBool());
481         });
482     }
483 
484     return editor;
485 }
486 
487 
488 /**
489  * Allows interactively resizing each column, but also stretches a single one
490  * when the header gets resized.
491  *
492  * Based on HeaderViewStretcher from Qt Creator.
493  */
494 class CustomStretchColumnHeaderView : public QHeaderView
495 {
496     Q_OBJECT
497 
498 public:
499     CustomStretchColumnHeaderView(QWidget *parent = nullptr);
500 
501     void initialize();
502 
503 protected:
504     void resizeEvent(QResizeEvent *event) override;
505     void showEvent(QShowEvent *event) override;
506     void hideEvent(QHideEvent *event) override;
507 
508 private:
509     const int mColumnToStretch = 1;
510 };
511 
CustomStretchColumnHeaderView(QWidget * parent)512 CustomStretchColumnHeaderView::CustomStretchColumnHeaderView(QWidget *parent)
513     : QHeaderView(Qt::Horizontal, parent)
514 {
515     setStretchLastSection(true);
516 }
517 
518 /**
519  * Should be called after setting the model and the header on the view, but
520  * before the view is shown.
521  */
initialize()522 void CustomStretchColumnHeaderView::initialize()
523 {
524     for (int i = 0; i < count(); ++i)
525         setSectionResizeMode(i, i == mColumnToStretch ? Stretch : ResizeToContents);
526 }
527 
resizeEvent(QResizeEvent * event)528 void CustomStretchColumnHeaderView::resizeEvent(QResizeEvent *event)
529 {
530     if (sectionResizeMode(mColumnToStretch) == QHeaderView::Interactive) {
531         int diff = event->size().width() - event->oldSize().width();
532         resizeSection(mColumnToStretch, qMax(32, sectionSize(mColumnToStretch) + diff));
533     }
534     QHeaderView::resizeEvent(event);
535 }
536 
showEvent(QShowEvent * event)537 void CustomStretchColumnHeaderView::showEvent(QShowEvent *event)
538 {
539     for (int i = 0; i < count(); ++i)
540         setSectionResizeMode(i, QHeaderView::Interactive);
541     QHeaderView::showEvent(event);
542 }
543 
hideEvent(QHideEvent * event)544 void CustomStretchColumnHeaderView::hideEvent(QHideEvent *event)
545 {
546     initialize();
547     QHeaderView::hideEvent(event);
548 }
549 
550 
551 /**
552  * The actual settings page for editing keyboard shortcuts.
553  */
ShortcutSettingsPage(QWidget * parent)554 ShortcutSettingsPage::ShortcutSettingsPage(QWidget *parent)
555     : QWidget(parent)
556     , ui(new Ui::ShortcutSettingsPage)
557     , mActionsModel(new ActionsModel(this))
558     , mProxyModel(new KeySequenceFilterModel(this))
559 {
560     ui->setupUi(this);
561 
562     ui->conflictsLabel->setVisible(false);
563 
564     ui->filterEdit->setFilteredView(ui->shortcutsView);
565 
566     mProxyModel->setSourceModel(mActionsModel);
567     mProxyModel->setSortLocaleAware(true);
568     mProxyModel->setSortCaseSensitivity(Qt::CaseInsensitive);
569     mProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
570     mProxyModel->setFilterKeyColumn(-1);
571     mProxyModel->setDynamicSortFilter(false);   // Can mess up ShortcutEditor interaction
572 
573     auto header = new CustomStretchColumnHeaderView(this);
574 
575     ui->shortcutsView->setModel(mProxyModel);
576     ui->shortcutsView->setHeader(header);
577     ui->shortcutsView->sortByColumn(0, Qt::AscendingOrder);
578     ui->shortcutsView->setItemDelegateForColumn(2, new ShortcutDelegate);
579 
580     header->initialize();
581 
582     connect(ui->filterEdit, &QLineEdit::textChanged,
583             mProxyModel, &KeySequenceFilterModel::setFilter);
584 
585     connect(ui->resetButton, &QPushButton::clicked, this, [this] {
586         ActionManager::instance()->resetAllCustomShortcuts();
587         mActionsModel->refresh();
588     });
589 
590     connect(ui->shortcutsView, &QAbstractItemView::activated,
591             this, [this] (const QModelIndex &index) {
592         if (index.isValid()) {
593             auto shortcutIndex = mProxyModel->index(index.row(), 2);
594             ui->shortcutsView->setCurrentIndex(shortcutIndex);  // Makes sure editor closes when current index changes
595             ui->shortcutsView->edit(shortcutIndex);
596         }
597     });
598 
599     connect(ui->shortcutsView->selectionModel(), &QItemSelectionModel::currentRowChanged,
600             this, &ShortcutSettingsPage::refreshConflicts);
601     connect(mProxyModel, &QAbstractItemModel::dataChanged,
602             this, &ShortcutSettingsPage::refreshConflicts);
603 
604     connect(ui->conflictsLabel, &QLabel::linkActivated,
605             this, &ShortcutSettingsPage::searchConflicts);
606 
607     connect(ui->importButton, &QPushButton::clicked,
608             this, &ShortcutSettingsPage::importShortcuts);
609     connect(ui->exportButton, &QPushButton::clicked,
610             this, &ShortcutSettingsPage::exportShortcuts);
611 }
612 
~ShortcutSettingsPage()613 ShortcutSettingsPage::~ShortcutSettingsPage()
614 {
615     QWidget *w = ui->shortcutsView->indexWidget(ui->shortcutsView->currentIndex());
616     if (auto shortcutEditor = qobject_cast<ShortcutEditor*>(w))
617         emit shortcutEditor->editingFinished();
618 
619     delete ui;
620 }
621 
sizeHint() const622 QSize ShortcutSettingsPage::sizeHint() const
623 {
624     QSize size = QWidget::sizeHint();
625     size.setWidth(Utils::dpiScaled(500));
626     return size;
627 }
628 
showEvent(QShowEvent * event)629 void ShortcutSettingsPage::showEvent(QShowEvent *event)
630 {
631     mActionsModel->setVisible(true);
632     QWidget::showEvent(event);
633 }
634 
hideEvent(QHideEvent * event)635 void ShortcutSettingsPage::hideEvent(QHideEvent *event)
636 {
637     mActionsModel->setVisible(false);
638     QWidget::hideEvent(event);
639 }
640 
refreshConflicts()641 void ShortcutSettingsPage::refreshConflicts()
642 {
643     auto current = ui->shortcutsView->currentIndex();
644     bool conflicts = current.isValid() &&
645             mProxyModel->data(current, ActionsModel::HasConflictingShortcut).toBool();
646     ui->conflictsLabel->setVisible(conflicts);
647 }
648 
searchConflicts()649 void ShortcutSettingsPage::searchConflicts()
650 {
651     auto current = ui->shortcutsView->currentIndex();
652     if (current.isValid()) {
653         auto filterSequence = mProxyModel->data(current, Qt::EditRole).value<QKeySequence>();
654         ui->filterEdit->setText(QLatin1String("key:") + filterSequence.toString(QKeySequence::NativeText));
655     }
656 }
657 
importShortcuts()658 void ShortcutSettingsPage::importShortcuts()
659 {
660     QString filter = tr("Keyboard Mapping Scheme (*.kms)");
661     QString fileName = QFileDialog::getOpenFileName(this, tr("Import Shortcuts"),
662                                                     QString(), filter);
663 
664     if (fileName.isEmpty())
665         return;
666 
667     QFile file(fileName);
668     if (!file.open(QFile::ReadOnly | QIODevice::Text)) {
669         QMessageBox::critical(this,
670                               tr("Error Loading Shortcuts"),
671                               QCoreApplication::translate("File Errors", "Could not open file for reading."));
672         return;
673     }
674 
675     QXmlStreamReader xml(&file);
676 
677     if (!xml.readNextStartElement() || xml.name() != QLatin1String("mapping")) {
678         QMessageBox::critical(this,
679                               tr("Error Loading Shortcuts"),
680                               tr("Invalid shortcuts file."));
681         return;
682     }
683 
684     QHash<Id, QKeySequence> result;
685 
686     while (xml.readNextStartElement()) {
687         if (xml.name() == QLatin1String("shortcut")) {
688             const Id id { xml.attributes().value(QLatin1String("id")).toUtf8() };
689 
690             while (xml.readNextStartElement()) {
691                 if (xml.name() == QLatin1String("key")) {
692                     QString keyString = xml.attributes().value(QLatin1String("value")).toString();
693                     result.insert(id, QKeySequence(keyString));
694                     xml.skipCurrentElement();   // skip out of "key" element
695                     xml.skipCurrentElement();   // skip out of "shortcut" element
696                     break;
697                 } else {
698                     xml.skipCurrentElement();   // skip unknown element
699                 }
700             }
701         } else {
702             xml.skipCurrentElement();           // skip unknown element
703         }
704     }
705 
706     ActionManager::instance()->setCustomShortcuts(result);
707     mActionsModel->refresh();
708 }
709 
exportShortcuts()710 void ShortcutSettingsPage::exportShortcuts()
711 {
712     QString filter = tr("Keyboard Mapping Scheme (*.kms)");
713     QString fileName = QFileDialog::getSaveFileName(this, tr("Export Shortcuts"),
714                                                     QString(), filter);
715 
716     if (fileName.isEmpty())
717         return;
718 
719     SaveFile file(fileName);
720 
721     if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
722         QMessageBox::critical(this,
723                               tr("Error Saving Shortcuts"),
724                               QCoreApplication::translate("File Errors", "Could not open file for writing."));
725         return;
726     }
727 
728     QXmlStreamWriter xml(file.device());
729 
730     xml.setAutoFormatting(true);
731     xml.setAutoFormattingIndent(1);
732 
733     xml.writeStartDocument();
734     xml.writeDTD(QLatin1String("<!DOCTYPE KeyboardMappingScheme>"));
735     xml.writeComment(QStringLiteral(" Written by %1 %2, %3. ").
736                      arg(QApplication::applicationDisplayName(),
737                          QApplication::applicationVersion(),
738                          QDateTime::currentDateTime().toString(Qt::ISODate)));
739     xml.writeStartElement(QStringLiteral("mapping"));
740 
741     auto actions = ActionManager::actions();
742     std::sort(actions.begin(), actions.end());
743 
744     for (Id actionId : qAsConst(actions)) {
745         const auto action = ActionManager::action(actionId);
746         const auto shortcut = action->shortcut();
747 
748         xml.writeStartElement(QStringLiteral("shortcut"));
749         xml.writeAttribute(QStringLiteral("id"), actionId.toString());
750 
751         if (!shortcut.isEmpty()) {
752             xml.writeEmptyElement(QLatin1String("key"));
753             xml.writeAttribute(QStringLiteral("value"), shortcut.toString());
754         }
755 
756         xml.writeEndElement();  // shortcut
757     }
758 
759     xml.writeEndElement();  // mapping
760     xml.writeEndDocument();
761 
762     if (!file.commit()) {
763         QMessageBox::critical(this,
764                               tr("Error Saving Shortcuts"),
765                               file.errorString());
766     }
767 }
768 
769 } // namespace Tiled
770 
771 #include "shortcutsettingspage.moc"
772 #include "moc_shortcutsettingspage.cpp"
773