diff --git a/framework/CMakeLists.txt b/framework/CMakeLists.txt index 4abbd8f24b..43506094d8 100644 --- a/framework/CMakeLists.txt +++ b/framework/CMakeLists.txt @@ -113,12 +113,6 @@ if (MUSE_MODULE_RCONTROL) add_subdirectory(rcontrol) endif() -if (MUSE_MODULE_SHORTCUTS_V2) - file(CREATE_LINK ${CMAKE_CURRENT_LIST_DIR}/shortcuts_v2 ${PROJECT_BINARY_DIR}/shortcuts SYMBOLIC) -else() - file(REMOVE_RECURSE ${PROJECT_BINARY_DIR}/shortcuts) -endif() - if (MUSE_MODULE_SHORTCUTS) if (MUSE_MODULE_SHORTCUTS_V2) add_subdirectory(shortcuts_v2) diff --git a/framework/shortcuts/ishortcutsconfiguration.h b/framework/shortcuts/ishortcutsconfiguration.h index 5700bc9bf1..0849e750a6 100644 --- a/framework/shortcuts/ishortcutsconfiguration.h +++ b/framework/shortcuts/ishortcutsconfiguration.h @@ -19,6 +19,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#pragma once + +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/ishortcutsconfiguration.h" +#else #pragma once @@ -41,3 +48,5 @@ class IShortcutsConfiguration : MODULE_GLOBAL_INTERFACE virtual io::path_t shortcutsAppDataPath() const = 0; }; } + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts/ishortcutscontroller.h b/framework/shortcuts/ishortcutscontroller.h index e29672d208..c086594282 100644 --- a/framework/shortcuts/ishortcutscontroller.h +++ b/framework/shortcuts/ishortcutscontroller.h @@ -19,6 +19,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#pragma once + +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/ishortcutscontroller.h" +#else + #ifndef MUSE_SHORTCUTS_ISHORTCUTSCONTROLLER_H #define MUSE_SHORTCUTS_ISHORTCUTSCONTROLLER_H @@ -41,3 +49,5 @@ class IShortcutsController : MODULE_CONTEXT_INTERFACE } #endif // MUSE_SHORTCUTS_ISHORTCUTSCONTROLLER_H + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts/ishortcutsregister.h b/framework/shortcuts/ishortcutsregister.h index 608d6b4250..f49c45f2ab 100644 --- a/framework/shortcuts/ishortcutsregister.h +++ b/framework/shortcuts/ishortcutsregister.h @@ -21,6 +21,14 @@ */ #pragma once +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/ishortcutsregister.h" +#else + +#pragma once + #include "modularity/imoduleinterface.h" #include "shortcutstypes.h" #include "async/notification.h" @@ -58,3 +66,5 @@ class IShortcutsRegister : MODULE_CONTEXT_INTERFACE virtual void reload(bool onlyDef = false) = 0; }; } + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts/shortcutcontext.h b/framework/shortcuts/shortcutcontext.h index aed34b5612..57f67f177e 100644 --- a/framework/shortcuts/shortcutcontext.h +++ b/framework/shortcuts/shortcutcontext.h @@ -19,6 +19,14 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#pragma once + +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/shortcutcontext.h" +#else + #ifndef MUSE_SHORTCUTS_SHORTCUTCONTEXT_H #define MUSE_SHORTCUTS_SHORTCUTCONTEXT_H @@ -51,3 +59,5 @@ class IShortcutContextPriority : MODULE_CONTEXT_INTERFACE } #endif // MUSE_SHORTCUTS_SHORTCUTCONTEXT_H + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts/shortcutsmodule.h b/framework/shortcuts/shortcutsmodule.h index c6f36d8e47..f755d34e5d 100644 --- a/framework/shortcuts/shortcutsmodule.h +++ b/framework/shortcuts/shortcutsmodule.h @@ -19,6 +19,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#pragma once + +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/shortcutsmodule.h" +#else #pragma once @@ -60,3 +67,5 @@ class ShortcutsContext : public modularity::IContextSetup std::shared_ptr m_shortcutsRegister; }; } + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts/shortcutstypes.h b/framework/shortcuts/shortcutstypes.h index 7b77b56a2b..676b83b24c 100644 --- a/framework/shortcuts/shortcutstypes.h +++ b/framework/shortcuts/shortcutstypes.h @@ -19,6 +19,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +#pragma once + +#include "muse_framework_config.h" + +#ifdef MUSE_MODULE_SHORTCUTS_V2 +#include "../shortcuts_v2/shortcutstypes.h" +#else #pragma once @@ -137,3 +144,5 @@ inline bool areContextPrioritiesEqual(const std::string& shortcutCtx1, const std return shortcutCtx1 == shortcutCtx2; } } + +#endif // MUSE_MODULE_SHORTCUTS_V2 diff --git a/framework/shortcuts_v2/CMakeLists.txt b/framework/shortcuts_v2/CMakeLists.txt index aaeb8ab9a1..a9ab75c558 100644 --- a/framework/shortcuts_v2/CMakeLists.txt +++ b/framework/shortcuts_v2/CMakeLists.txt @@ -51,3 +51,7 @@ target_link_libraries(muse_shortcuts PRIVATE Qt::Gui) if (MUSE_MODULE_SHORTCUTS_QML) add_subdirectory(qml/Muse/Shortcuts) endif() + +if (MUSE_MODULE_SHORTCUTS_TESTS) + add_subdirectory(tests) +endif() diff --git a/framework/shortcuts_v2/icommandshortcutsregister.h b/framework/shortcuts_v2/icommandshortcutsregister.h index 917f03115d..cd29f041d0 100644 --- a/framework/shortcuts_v2/icommandshortcutsregister.h +++ b/framework/shortcuts_v2/icommandshortcutsregister.h @@ -22,8 +22,12 @@ #pragma once +#include +#include + #include "modularity/imoduleinterface.h" #include "shortcutstypes.h" +#include "async/channel.h" #include "async/notification.h" #include "types/ret.h" #include "io/path.h" @@ -47,6 +51,16 @@ class ICommandShortcutsRegister : MODULE_GLOBAL_INTERFACE virtual Ret importFromFile(const io::path_t& filePath) = 0; virtual Ret exportToFile(const io::path_t& filePath) const = 0; + virtual std::vector availablePresets() const = 0; + + virtual std::string currentPresetName() const = 0; + virtual void setCurrentPresetName(const std::string& presetName) = 0; + virtual async::Channel currentPresetNameChanged() const = 0; + + virtual bool isPresetEdited(const std::string& presetName) const = 0; + virtual bool canDeletePreset(const std::string& presetName) const = 0; + virtual void deletePreset(const std::string& presetName) = 0; + // for testflow tests virtual void reload(bool onlyDef = false) = 0; }; diff --git a/framework/shortcuts_v2/internal/commandshortcutsregister.cpp b/framework/shortcuts_v2/internal/commandshortcutsregister.cpp index 5b33b2383e..30b8ce86c1 100644 --- a/framework/shortcuts_v2/internal/commandshortcutsregister.cpp +++ b/framework/shortcuts_v2/internal/commandshortcutsregister.cpp @@ -21,6 +21,7 @@ */ #include "commandshortcutsregister.h" +#include "containers.h" #include "global/io/file.h" #include "global/serialization/json.h" @@ -31,6 +32,7 @@ using namespace muse::shortcuts; using namespace muse::async; static const std::string COMMAND_SHORTCUTS_TAG("CommandShortcuts"); +static const std::string DEFAULT_SHORTCUTS_NAME("shortcuts"); void CommandShortcutsRegister::init() { @@ -40,6 +42,10 @@ void CommandShortcutsRegister::init() } }); + configuration()->currentShortcutsPresetNameChanged().onReceive(this, [this](const std::string&) { + reload(); + }); + reload(); } @@ -50,11 +56,24 @@ void CommandShortcutsRegister::reload(bool onlyDef) m_shortcuts.clear(); m_defaultShortcuts.clear(); - io::path_t defPath = configuration()->commandShortcutsAppDataPath(); - io::path_t userPath = configuration()->commandShortcutsUserAppDataPath(); + io::path_t defPath = configuration()->commandShortcutsAppDataPath(DEFAULT_SHORTCUTS_NAME); + io::path_t userPath = userShortcutsPath(); bool ok = readFromFile(m_defaultShortcuts, defPath); + if (ok) { + //! NOTE Platform and preset shortcuts files are diffs from the base one + std::string defaultName = configuration()->defaultShortcutsName(); + if (defaultName != DEFAULT_SHORTCUTS_NAME) { + applyShortcutsDiff(defaultName, m_defaultShortcuts); + } + + std::string presetName = configuration()->currentShortcutsPresetName(); + if (!presetName.empty()) { + applyShortcutsDiff(presetName, m_defaultShortcuts); + } + } + if (ok) { if (!onlyDef) { if (!io::File::exists(userPath)) { @@ -84,6 +103,66 @@ void CommandShortcutsRegister::reload(bool onlyDef) } } +std::string CommandShortcutsRegister::activeShortcutsName() const +{ + std::string presetName = configuration()->currentShortcutsPresetName(); + return presetName.empty() ? configuration()->defaultShortcutsName() : presetName; +} + +io::path_t CommandShortcutsRegister::userShortcutsPath() const +{ + return configuration()->commandShortcutsUserAppDataPath(activeShortcutsName()); +} + +void CommandShortcutsRegister::applyShortcutsDiff(const std::string& shortcutsName, ShortcutList& shortcuts) const +{ + ShortcutList diff; + if (!readFromFile(diff, configuration()->commandShortcutsAppDataPath(shortcutsName))) { + return; + } + + mergeShortcuts(diff, shortcuts); + shortcuts = diff; +} + +//! NOTE Scope and autoRepeat always take default values on merge, so only sequences are compared +ShortcutList CommandShortcutsRegister::makeDiff(const ShortcutList& shortcuts, const ShortcutList& defaultShortcuts) const +{ + ShortcutList diff; + + for (const Shortcut& sc : shortcuts) { + auto it = std::find_if(defaultShortcuts.begin(), defaultShortcuts.end(), [&sc](const Shortcut& defSc) { + return defSc.command == sc.command; + }); + + if (it == defaultShortcuts.end() || it->sequences != sc.sequences) { + diff.push_back(sc); + } + } + + return diff; +} + +bool CommandShortcutsRegister::removeUserFile() +{ + io::path_t path = userShortcutsPath(); + if (path.empty()) { + return false; + } + + bool ok = false; + { + mi::WriteResourceLockGuard guard(multiwindowsProvider(), COMMAND_SHORTCUTS_TAG); + ok = io::File::remove(path); + } + + if (!ok) { + LOGE() << "failed remove file: " << path; + } + + return ok; +} + void CommandShortcutsRegister::mergeShortcuts(ShortcutList& shortcuts, const ShortcutList& defaultShortcuts) const { TRACEFUNC; @@ -240,6 +319,11 @@ bool CommandShortcutsRegister::writeToFile(const ShortcutList& shortcuts, const { TRACEFUNC; + if (path.empty()) { + LOGE() << "failed write shortcuts: empty path"; + return false; + } + JsonObject root; for (const Shortcut& shortcut : shortcuts) { JsonObject shortcutObj; @@ -291,11 +375,17 @@ Ret CommandShortcutsRegister::setShortcuts(const ShortcutList& shortcuts) } ShortcutList needToWrite = filterAndUpdateAdditionalShortcuts(shortcuts); + ShortcutList diff = makeDiff(needToWrite, m_defaultShortcuts); - bool ok = writeToFile(needToWrite, configuration()->commandShortcutsUserAppDataPath()); + bool ok = false; + if (diff.empty()) { + ok = removeUserFile(); + } else { + ok = writeToFile(diff, userShortcutsPath()); + } if (ok) { - m_shortcuts = needToWrite; + m_shortcuts = diff; mergeShortcuts(m_shortcuts, m_defaultShortcuts); mergeAdditionalShortcuts(m_shortcuts); m_shortcutsChanged.notify(); @@ -306,11 +396,8 @@ Ret CommandShortcutsRegister::setShortcuts(const ShortcutList& shortcuts) void CommandShortcutsRegister::resetShortcuts() { - { - mi::WriteResourceLockGuard guard(multiwindowsProvider(), COMMAND_SHORTCUTS_TAG); - io::File::remove(configuration()->commandShortcutsUserAppDataPath()); - } - + //! NOTE Even if the removal failed, reload to keep the state in sync with the file + removeUserFile(); reload(); } @@ -343,9 +430,15 @@ ShortcutList CommandShortcutsRegister::shortcutsForSequence(const std::string& s Ret CommandShortcutsRegister::importFromFile(const io::path_t& filePath) { + io::path_t userPath = userShortcutsPath(); + if (userPath.empty()) { + LOGE() << "failed import file: invalid user shortcuts path"; + return make_ret(Ret::Code::UnknownError); + } + { - mi::ReadResourceLockGuard guard(multiwindowsProvider(), COMMAND_SHORTCUTS_TAG); - Ret ret = io::File::copy(filePath, configuration()->commandShortcutsUserAppDataPath(), true); + mi::WriteResourceLockGuard guard(multiwindowsProvider(), COMMAND_SHORTCUTS_TAG); + Ret ret = io::File::copy(filePath, userPath, true); if (!ret) { LOGE() << "failed import file: " << ret.toString(); return ret; @@ -361,3 +454,66 @@ Ret CommandShortcutsRegister::exportToFile(const io::path_t& filePath) const { return writeToFile(m_shortcuts, filePath); } + +std::vector CommandShortcutsRegister::availablePresets() const +{ + return configuration()->availableShortcutsPresets(); +} + +std::string CommandShortcutsRegister::currentPresetName() const +{ + return configuration()->currentShortcutsPresetName(); +} + +void CommandShortcutsRegister::setCurrentPresetName(const std::string& presetName) +{ + configuration()->setCurrentShortcutsPresetName(presetName); +} + +async::Channel CommandShortcutsRegister::currentPresetNameChanged() const +{ + return configuration()->currentShortcutsPresetNameChanged(); +} + +bool CommandShortcutsRegister::isPresetEdited(const std::string& presetName) const +{ + std::string name = presetName.empty() ? configuration()->defaultShortcutsName() : presetName; + return io::File::exists(configuration()->commandShortcutsUserAppDataPath(name)); +} + +bool CommandShortcutsRegister::canDeletePreset(const std::string& presetName) const +{ + if (presetName.empty()) { + return false; + } + + //! NOTE Built-in presets cannot be deleted + return !muse::contains(configuration()->availableShortcutsPresets(), presetName); +} + +void CommandShortcutsRegister::deletePreset(const std::string& presetName) +{ + if (!canDeletePreset(presetName)) { + return; + } + + io::path_t path = configuration()->commandShortcutsUserAppDataPath(presetName); + if (path.empty()) { + return; + } + + bool ok = false; + { + mi::WriteResourceLockGuard guard(multiwindowsProvider(), COMMAND_SHORTCUTS_TAG); + ok = io::File::remove(path); + } + + if (!ok) { + LOGE() << "failed remove file: " << path; + return; + } + + if (currentPresetName() == presetName) { + setCurrentPresetName(std::string()); + } +} diff --git a/framework/shortcuts_v2/internal/commandshortcutsregister.h b/framework/shortcuts_v2/internal/commandshortcutsregister.h index 6f4e52c899..6e3c044c1d 100644 --- a/framework/shortcuts_v2/internal/commandshortcutsregister.h +++ b/framework/shortcuts_v2/internal/commandshortcutsregister.h @@ -57,11 +57,29 @@ class CommandShortcutsRegister : public ICommandShortcutsRegister, public async: Ret importFromFile(const io::path_t& filePath) override; Ret exportToFile(const io::path_t& filePath) const override; + std::vector availablePresets() const override; + + std::string currentPresetName() const override; + void setCurrentPresetName(const std::string& presetName) override; + async::Channel currentPresetNameChanged() const override; + + bool isPresetEdited(const std::string& presetName) const override; + bool canDeletePreset(const std::string& presetName) const override; + void deletePreset(const std::string& presetName) override; + // for testflow tests void reload(bool onlyDef = false) override; private: + std::string activeShortcutsName() const; + io::path_t userShortcutsPath() const; + + void applyShortcutsDiff(const std::string& shortcutsName, ShortcutList& shortcuts) const; + ShortcutList makeDiff(const ShortcutList& shortcuts, const ShortcutList& defaultShortcuts) const; + + bool removeUserFile(); + bool readFromFile(ShortcutList& shortcuts, const io::path_t& path) const; bool writeToFile(const ShortcutList& shortcuts, const io::path_t& path) const; diff --git a/framework/shortcuts_v2/internal/shortcutsconfiguration.cpp b/framework/shortcuts_v2/internal/shortcutsconfiguration.cpp index 90fa73e191..149adbcc1a 100644 --- a/framework/shortcuts_v2/internal/shortcutsconfiguration.cpp +++ b/framework/shortcuts_v2/internal/shortcutsconfiguration.cpp @@ -31,9 +31,18 @@ using namespace muse; using namespace muse::shortcuts; +static const muse::Settings::Key CURRENT_SHORTCUTS_PRESET_KEY("shortcuts", "shortcuts/currentPreset"); + void ShortcutsConfiguration::init() { m_config = ConfigReader::read(":/configs/shortcuts.cfg"); + + settings()->setDefaultValue(CURRENT_SHORTCUTS_PRESET_KEY, Val("")); + settings()->valueChanged(CURRENT_SHORTCUTS_PRESET_KEY).onReceive(this, [this](const Val& name) { + m_currentPresetNameChanged.send(name.toString()); + }); + + fileSystem()->makePath(userShortcutsDirPath()); } QString ShortcutsConfiguration::currentKeyboardLayout() const @@ -63,16 +72,58 @@ io::path_t ShortcutsConfiguration::shortcutsAppDataPath() const #endif } -io::path_t ShortcutsConfiguration::commandShortcutsUserAppDataPath() const -{ - return globalConfiguration()->userAppDataPath() + "/shortcuts.json"; -} - -io::path_t ShortcutsConfiguration::commandShortcutsAppDataPath() const +std::string ShortcutsConfiguration::defaultShortcutsName() const { #if defined(Q_OS_MACOS) - return m_config.value("command_shortcuts_mac").toPath(); + return "shortcuts_mac"; #else - return m_config.value("command_shortcuts").toPath(); + return "shortcuts"; #endif } + +std::vector ShortcutsConfiguration::availableShortcutsPresets() const +{ + std::vector result; + + ValList presets = m_config.value("command_shortcuts_presets").toList(); + for (const Val& preset : presets) { + result.push_back(preset.toString()); + } + + return result; +} + +std::string ShortcutsConfiguration::currentShortcutsPresetName() const +{ + return settings()->value(CURRENT_SHORTCUTS_PRESET_KEY).toString(); +} + +void ShortcutsConfiguration::setCurrentShortcutsPresetName(const std::string& name) +{ + settings()->setSharedValue(CURRENT_SHORTCUTS_PRESET_KEY, Val(name)); +} + +async::Channel ShortcutsConfiguration::currentShortcutsPresetNameChanged() const +{ + return m_currentPresetNameChanged; +} + +io::path_t ShortcutsConfiguration::userShortcutsDirPath() const +{ + return globalConfiguration()->userAppDataPath() + "/shortcuts"; +} + +io::path_t ShortcutsConfiguration::commandShortcutsUserAppDataPath(const std::string& shortcutsName) const +{ + if (!io::isAllowedFileName(shortcutsName)) { + LOGE() << "invalid shortcuts name: " << shortcutsName; + return io::path_t(); + } + + return userShortcutsDirPath() + "/" + shortcutsName + ".json"; +} + +io::path_t ShortcutsConfiguration::commandShortcutsAppDataPath(const std::string& shortcutsName) const +{ + return m_config.value("command_" + shortcutsName).toPath(); +} diff --git a/framework/shortcuts_v2/internal/shortcutsconfiguration.h b/framework/shortcuts_v2/internal/shortcutsconfiguration.h index 9c47701260..a298d0a639 100644 --- a/framework/shortcuts_v2/internal/shortcutsconfiguration.h +++ b/framework/shortcuts_v2/internal/shortcutsconfiguration.h @@ -25,6 +25,7 @@ #include "modularity/ioc.h" #include "iglobalconfiguration.h" +#include "io/ifilesystem.h" #include "ishortcutsconfiguration.h" @@ -34,6 +35,7 @@ namespace muse::shortcuts { class ShortcutsConfiguration : public IShortcutsConfiguration, public Contextable, public async::Asyncable { GlobalInject globalConfiguration; + GlobalInject fileSystem; public: ShortcutsConfiguration(const modularity::ContextPtr& iocCtx) @@ -47,10 +49,20 @@ class ShortcutsConfiguration : public IShortcutsConfiguration, public Contextabl io::path_t shortcutsUserAppDataPath() const override; io::path_t shortcutsAppDataPath() const override; - io::path_t commandShortcutsUserAppDataPath() const override; - io::path_t commandShortcutsAppDataPath() const override; + std::string defaultShortcutsName() const override; + std::vector availableShortcutsPresets() const override; + + std::string currentShortcutsPresetName() const override; + void setCurrentShortcutsPresetName(const std::string& name) override; + async::Channel currentShortcutsPresetNameChanged() const override; + + io::path_t commandShortcutsUserAppDataPath(const std::string& shortcutsName) const override; + io::path_t commandShortcutsAppDataPath(const std::string& shortcutsName) const override; private: + io::path_t userShortcutsDirPath() const; + Config m_config; + async::Channel m_currentPresetNameChanged; }; } diff --git a/framework/shortcuts_v2/ishortcutsconfiguration.h b/framework/shortcuts_v2/ishortcutsconfiguration.h index 7d78e26665..d9355d6bc1 100644 --- a/framework/shortcuts_v2/ishortcutsconfiguration.h +++ b/framework/shortcuts_v2/ishortcutsconfiguration.h @@ -22,9 +22,13 @@ #pragma once +#include +#include + #include "modularity/imoduleinterface.h" #include "io/path.h" #include "types/retval.h" +#include "async/channel.h" namespace muse::shortcuts { class IShortcutsConfiguration : MODULE_GLOBAL_INTERFACE @@ -40,7 +44,14 @@ class IShortcutsConfiguration : MODULE_GLOBAL_INTERFACE virtual io::path_t shortcutsUserAppDataPath() const = 0; virtual io::path_t shortcutsAppDataPath() const = 0; - virtual io::path_t commandShortcutsUserAppDataPath() const = 0; - virtual io::path_t commandShortcutsAppDataPath() const = 0; + virtual std::string defaultShortcutsName() const = 0; + virtual std::vector availableShortcutsPresets() const = 0; + + virtual std::string currentShortcutsPresetName() const = 0; + virtual void setCurrentShortcutsPresetName(const std::string& name) = 0; + virtual async::Channel currentShortcutsPresetNameChanged() const = 0; + + virtual io::path_t commandShortcutsUserAppDataPath(const std::string& shortcutsName) const = 0; + virtual io::path_t commandShortcutsAppDataPath(const std::string& shortcutsName) const = 0; }; } diff --git a/framework/shortcuts_v2/qml/Muse/Shortcuts/ShortcutsPage.qml b/framework/shortcuts_v2/qml/Muse/Shortcuts/ShortcutsPage.qml index 2ccb569051..21c3b98f2c 100644 --- a/framework/shortcuts_v2/qml/Muse/Shortcuts/ShortcutsPage.qml +++ b/framework/shortcuts_v2/qml/Muse/Shortcuts/ShortcutsPage.qml @@ -89,6 +89,11 @@ Item { buttonMinWidth: prv.buttonMinWidth + presets: shortcutsModel.presets + currentPresetName: shortcutsModel.currentPresetName + isCurrentPresetEdited: shortcutsModel.isCurrentPresetEdited + canDeleteCurrentPreset: shortcutsModel.canDeleteCurrentPreset + navigation.section: root.navigationSection navigation.order: root.navigationOrderStart + 1 @@ -99,6 +104,18 @@ Item { onClearSelectedShortcutsRequested: { shortcutsModel.clearSelectedShortcuts() } + + onPresetChangeRequested: function(presetName) { + shortcutsModel.currentPresetName = presetName + } + + onResetPresetRequested: { + shortcutsModel.resetCurrentPreset() + } + + onDeletePresetRequested: { + shortcutsModel.deleteCurrentPreset() + } } ShortcutsList { diff --git a/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsTopPanel.qml b/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsTopPanel.qml index b788ea1d1d..a257ed98cd 100644 --- a/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsTopPanel.qml +++ b/framework/shortcuts_v2/qml/Muse/Shortcuts/internal/ShortcutsTopPanel.qml @@ -26,7 +26,7 @@ import QtQuick.Layouts import Muse.Ui import Muse.UiComponents -RowLayout { +ColumnLayout { id: root property alias canEditCurrentShortcut: editButton.enabled @@ -36,13 +36,22 @@ RowLayout { property int buttonMinWidth: 0 + property var presets: null + property string currentPresetName: "" + property bool isCurrentPresetEdited: false + property bool canDeleteCurrentPreset: false + signal startEditCurrentShortcutRequested() signal clearSelectedShortcutsRequested() + signal presetChangeRequested(string presetName) + signal resetPresetRequested() + signal deletePresetRequested() + + spacing: 12 property NavigationPanel navigation: NavigationPanel { name: "ShortcutsTopPanel" enabled: root.enabled && root.visible - direction: NavigationPanel.Horizontal accessible.name: qsTrc("shortcuts", "Shortcuts top panel") onActiveChanged: function(active) { @@ -56,49 +65,112 @@ RowLayout { searchField.currentText = text } - FlatButton { - id: editButton + RowLayout { + Layout.fillWidth: true + + StyledTextLabel { + text: qsTrc("shortcuts", "Presets:") + } + + StyledDropdown { + id: presetsDropdown + + Layout.fillWidth: true + + model: root.presets + textRole: "title" + valueRole: "name" - minWidth: root.buttonMinWidth + currentIndex: presetsDropdown.indexOfValue(root.currentPresetName) - text: qsTrc("shortcuts", "Define…") + navigation.name: "ShortcutsPresetDropdown" + navigation.panel: root.navigation + navigation.order: 1 - navigation.name: "DefineShortcutButton" - navigation.panel: root.navigation - navigation.column: 0 + onActivated: function(index, value) { + root.presetChangeRequested(value) + } + } + + FlatButton { + icon: IconCode.UNDO + toolTipTitle: qsTrc("shortcuts", "Reset preset") + + enabled: root.isCurrentPresetEdited - onClicked: { - root.startEditCurrentShortcutRequested() + navigation.name: "ResetPresetButton" + navigation.panel: root.navigation + navigation.order: 2 + + onClicked: { + root.resetPresetRequested() + } } + + // NOTE: enable after adding user's presets + // FlatButton { + // icon: IconCode.DELETE_TANK + // toolTipTitle: qsTrc("shortcuts", "Delete preset") + + // enabled: root.canDeleteCurrentPreset + + // navigation.name: "DeletePresetButton" + // navigation.panel: root.navigation + // navigation.order: 3 + + // onClicked: { + // root.deletePresetRequested() + // } + // } } - FlatButton { - id: clearButton + RowLayout { + Layout.fillWidth: true + + FlatButton { + id: editButton - minWidth: root.buttonMinWidth + minWidth: root.buttonMinWidth - text: qsTrc("global", "Clear") + text: qsTrc("shortcuts", "Define…") - navigation.name: "ClearShortcutsButton" - navigation.panel: root.navigation - navigation.column: 1 + navigation.name: "DefineShortcutButton" + navigation.panel: root.navigation + navigation.order: 4 - onClicked: { - root.clearSelectedShortcutsRequested() + onClicked: { + root.startEditCurrentShortcutRequested() + } } - } - Item { Layout.fillWidth: true } + FlatButton { + id: clearButton + + minWidth: root.buttonMinWidth - SearchField { - id: searchField + text: qsTrc("global", "Clear") - Layout.preferredWidth: 160 + navigation.name: "ClearShortcutsButton" + navigation.panel: root.navigation + navigation.order: 5 - hint: qsTrc("shortcuts", "Search shortcut") + onClicked: { + root.clearSelectedShortcutsRequested() + } + } + + Item { Layout.fillWidth: true } + + SearchField { + id: searchField - navigation.name: "ShortcutSearchField" - navigation.panel: root.navigation - navigation.column: 2 + Layout.preferredWidth: 160 + + hint: qsTrc("shortcuts", "Search shortcut") + + navigation.name: "ShortcutSearchField" + navigation.panel: root.navigation + navigation.order: 6 + } } } diff --git a/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.cpp b/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.cpp index 18aba29822..85cc4301c5 100644 --- a/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.cpp +++ b/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.cpp @@ -188,11 +188,18 @@ void ShortcutsModel::load() }, async::Asyncable::Mode::SetReplace); } + commandShortcutsRegister()->currentPresetNameChanged().onReceive(this, [this](const std::string&) { + emit currentPresetNameChanged(); + }, async::Asyncable::Mode::SetReplace); + std::sort(m_items.begin(), m_items.end(), [](const Item& i1, const Item& i2) { return i1.group > i2.group || (i1.group == i2.group && i1.title < i2.title); }); endResetModel(); + + m_hasUnsavedChanges = false; + emit presetsChanged(); } bool ShortcutsModel::apply() @@ -228,6 +235,9 @@ bool ShortcutsModel::apply() } } + m_hasUnsavedChanges = false; + emit presetsChanged(); + return true; } @@ -331,6 +341,8 @@ void ShortcutsModel::applySequenceToCurrentShortcut(const QString& newSequence, } notifyAboutShortcutChanged(currIndex); + + markUnsavedChanges(); } void ShortcutsModel::clearSelectedShortcuts() @@ -341,6 +353,10 @@ void ShortcutsModel::clearSelectedShortcuts() item.sequence = ""; notifyAboutShortcutChanged(index); } + + if (!m_selection.indexes().isEmpty()) { + markUnsavedChanges(); + } } void ShortcutsModel::notifyAboutShortcutChanged(const QModelIndex& index) @@ -387,6 +403,100 @@ void ShortcutsModel::resetToDefaultSelectedShortcuts() notifyAboutShortcutChanged(index); } + + if (!m_selection.indexes().isEmpty()) { + markUnsavedChanges(); + } +} + +QVariantList ShortcutsModel::presets() const +{ + auto makePreset = [this](const std::string& name, const QString& title) { + bool isEdited = isPresetEditedOrHasUnsavedChanges(name); + + QVariantMap preset; + preset["name"] = QString::fromStdString(name); + preset["title"] = isEdited ? title + " " + muse::qtrc("shortcuts", "(edited)") : title; + preset["isEdited"] = isEdited; + return preset; + }; + + QVariantList result; + result << makePreset(std::string(), muse::qtrc("shortcuts", "Default")); + + for (const std::string& name : commandShortcutsRegister()->availablePresets()) { + result << makePreset(name, presetTitle(name)); + } + + return result; +} + +bool ShortcutsModel::isPresetEditedOrHasUnsavedChanges(const std::string& presetName) const +{ + if (presetName == currentPresetName().toStdString() && m_hasUnsavedChanges) { + return true; + } + + return isPresetEdited(presetName); +} + +bool ShortcutsModel::isPresetEdited(const std::string& presetName) const +{ + return commandShortcutsRegister()->isPresetEdited(presetName); +} + +bool ShortcutsModel::isCurrentPresetEdited() const +{ + return m_hasUnsavedChanges || isPresetEdited(currentPresetName().toStdString()); +} + +void ShortcutsModel::markUnsavedChanges() +{ + if (!m_hasUnsavedChanges) { + m_hasUnsavedChanges = true; + emit presetsChanged(); + } +} + +bool ShortcutsModel::canDeleteCurrentPreset() const +{ + return commandShortcutsRegister()->canDeletePreset(currentPresetName().toStdString()); +} + +void ShortcutsModel::resetCurrentPreset() +{ + commandShortcutsRegister()->resetShortcuts(); +} + +void ShortcutsModel::deleteCurrentPreset() +{ + commandShortcutsRegister()->deletePreset(currentPresetName().toStdString()); +} + +QString ShortcutsModel::presetTitle(const std::string& presetName) const +{ + static const QString PREFIX("shortcuts_"); + + QString title = QString::fromStdString(presetName); + if (title.startsWith(PREFIX)) { + title = title.mid(PREFIX.length()); + } + + return title.toUpper(); +} + +QString ShortcutsModel::currentPresetName() const +{ + return QString::fromStdString(commandShortcutsRegister()->currentPresetName()); +} + +void ShortcutsModel::setCurrentPresetName(const QString& name) +{ + if (name == currentPresetName()) { + return; + } + + commandShortcutsRegister()->setCurrentPresetName(name.toStdString()); } QVariantList ShortcutsModel::shortcuts() const diff --git a/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.h b/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.h index d94747243a..38be4b46e7 100644 --- a/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.h +++ b/framework/shortcuts_v2/qml/Muse/Shortcuts/shortcutsmodel.h @@ -30,7 +30,6 @@ #include "modularity/ioc.h" #include "ishortcutsregister.h" #include "icommandshortcutsregister.h" -#include "ishortcutsconfiguration.h" #include "ui/iuiactionsregister.h" #include "async/asyncable.h" #include "interactive/iinteractive.h" @@ -47,9 +46,13 @@ class ShortcutsModel : public QAbstractListModel, public Contextable, public asy Q_PROPERTY(QItemSelection selection READ selection WRITE setSelection NOTIFY selectionChanged) Q_PROPERTY(QVariant currentShortcut READ currentShortcut NOTIFY selectionChanged) + Q_PROPERTY(QVariantList presets READ presets NOTIFY presetsChanged) + Q_PROPERTY(QString currentPresetName READ currentPresetName WRITE setCurrentPresetName NOTIFY currentPresetNameChanged) + Q_PROPERTY(bool isCurrentPresetEdited READ isCurrentPresetEdited NOTIFY presetsChanged) + Q_PROPERTY(bool canDeleteCurrentPreset READ canDeleteCurrentPreset NOTIFY presetsChanged) + QML_ELEMENT - GlobalInject configuration; GlobalInject globalConfiguration; GlobalInject commandsRegister; GlobalInject commandShortcutsRegister; @@ -67,6 +70,16 @@ class ShortcutsModel : public QAbstractListModel, public Contextable, public asy QItemSelection selection() const; QVariant currentShortcut() const; + QVariantList presets() const; + QString currentPresetName() const; + void setCurrentPresetName(const QString& name); + + bool isCurrentPresetEdited() const; + bool canDeleteCurrentPreset() const; + + Q_INVOKABLE void resetCurrentPreset(); + Q_INVOKABLE void deleteCurrentPreset(); + Q_INVOKABLE void load(); Q_INVOKABLE bool apply(); Q_INVOKABLE void reset(); @@ -86,8 +99,15 @@ public slots: signals: void selectionChanged(); + void presetsChanged(); + void currentPresetNameChanged(); private: + QString presetTitle(const std::string& presetName) const; + bool isPresetEdited(const std::string& presetName) const; + bool isPresetEditedOrHasUnsavedChanges(const std::string& presetName) const; + void markUnsavedChanges(); + const muse::ui::UiAction& action(const std::string& actionCode) const; QString actionText(const std::string& actionCode) const; @@ -117,5 +137,6 @@ public slots: QList m_items; QItemSelection m_selection; + bool m_hasUnsavedChanges = false; }; } diff --git a/framework/shortcuts_v2/tests/CMakeLists.txt b/framework/shortcuts_v2/tests/CMakeLists.txt new file mode 100644 index 0000000000..c3c985afd7 --- /dev/null +++ b/framework/shortcuts_v2/tests/CMakeLists.txt @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: GPL-3.0-only +# MuseScore-CLA-applies +# +# MuseScore Studio +# Music Composition & Notation +# +# Copyright (C) 2026 MuseScore Limited and others +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3 as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set(MODULE_TEST muse_shortcuts_tests) + +set(MODULE_TEST_SRC + ${CMAKE_CURRENT_LIST_DIR}/mocks/shortcutsconfigurationmock.h + ${CMAKE_CURRENT_LIST_DIR}/commandshortcutsregister_tests.cpp +) + +set(MODULE_TEST_LINK + muse::shortcuts +) + +set(MODULE_TEST_INCLUDE + ${MUSE_FRAMEWORK_SRC_PATH}/shortcuts_v2 + ${MUSE_FRAMEWORK_SRC_PATH}/multiwindows +) + +set(MODULE_TEST_DEF + SHORTCUTS_TESTDATA_DIR="${CMAKE_CURRENT_LIST_DIR}/testdata" +) + +include(SetupGTest) diff --git a/framework/shortcuts_v2/tests/commandshortcutsregister_tests.cpp b/framework/shortcuts_v2/tests/commandshortcutsregister_tests.cpp new file mode 100644 index 0000000000..f1d2ead69e --- /dev/null +++ b/framework/shortcuts_v2/tests/commandshortcutsregister_tests.cpp @@ -0,0 +1,499 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include + +#include +#include + +#include + +#include "modularity/ioc.h" +#include "async/asyncable.h" +#include "global/io/file.h" +#include "global/serialization/json.h" + +#include "internal/commandshortcutsregister.h" + +#include "mocks/shortcutsconfigurationmock.h" +#include "multiwindows/tests/mocks/multiwindowsprovidermock.h" + +using ::testing::_; +using ::testing::Invoke; +using ::testing::NiceMock; +using ::testing::Return; + +using namespace muse; +using namespace muse::shortcuts; + +namespace muse::shortcuts { +class Shortcuts_CommandShortcutsRegisterTests : public ::testing::Test, public async::Asyncable +{ +public: + void SetUp() override + { + m_userDir = std::make_unique(); + ASSERT_TRUE(m_userDir->isValid()); + m_userDirPath = m_userDir->path().toStdString(); + + m_configuration = std::make_shared >(); + m_multiWindowsProvider = std::make_shared >(); + + modularity::globalIoc()->registerExport("utests", m_configuration); + modularity::globalIoc()->registerExport("utests", m_multiWindowsProvider); + + ON_CALL(*m_configuration, defaultShortcutsName()) + .WillByDefault(Return("shortcuts")); + + ON_CALL(*m_configuration, availableShortcutsPresets()) + .WillByDefault(Return(std::vector { "shortcuts_azerty" })); + + ON_CALL(*m_configuration, commandShortcutsAppDataPath(_)) + .WillByDefault(Invoke([](const std::string& name) { + return io::path_t(std::string(SHORTCUTS_TESTDATA_DIR) + "/" + name + ".json"); + })); + + ON_CALL(*m_configuration, commandShortcutsUserAppDataPath(_)) + .WillByDefault(Invoke([this](const std::string& name) { + return userPath(name); + })); + + ON_CALL(*m_configuration, currentShortcutsPresetName()) + .WillByDefault(Invoke([this]() { + return m_currentPresetName; + })); + + ON_CALL(*m_configuration, currentShortcutsPresetNameChanged()) + .WillByDefault(Return(m_presetNameChanged)); + + ON_CALL(*m_multiWindowsProvider, resourceChanged()) + .WillByDefault(Return(m_resourceChanged)); + } + + void TearDown() override + { + m_register.reset(); + + modularity::globalIoc()->unregister("utests"); + modularity::globalIoc()->unregister("utests"); + } + + void initRegister() + { + m_register = std::make_unique(); + m_register->init(); + } + + io::path_t userPath(const std::string& name) const + { + return io::path_t(m_userDirPath + "/" + name + ".json"); + } + + void writeUserFile(const std::string& name, const std::string& content) + { + std::ofstream file(m_userDirPath + "/" + name + ".json"); + ASSERT_TRUE(file.is_open()); + file << content; + } + + void changeCurrentPreset(const std::string& name) + { + m_currentPresetName = name; + m_presetNameChanged.send(name); + } + + static const Shortcut* findShortcut(const ShortcutList& list, const std::string& command) + { + auto it = std::find_if(list.begin(), list.end(), [&command](const Shortcut& sc) { + return sc.command == command; + }); + + return it != list.end() ? &(*it) : nullptr; + } + + static std::vector seqs(std::initializer_list list) + { + return std::vector(list); + } + +protected: + std::unique_ptr m_userDir; + std::string m_userDirPath; + + std::string m_currentPresetName; + async::Channel m_presetNameChanged; + async::Channel m_resourceChanged; + + std::shared_ptr > m_configuration; + std::shared_ptr > m_multiWindowsProvider; + + std::unique_ptr m_register; +}; + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, LoadsDefaultShortcuts) +{ + //! [GIVEN] Default shortcuts name, no preset, no user file + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] The base shortcuts are loaded as is + const ShortcutList& shortcuts = m_register->shortcuts(); + EXPECT_EQ(shortcuts.size(), 4u); + + const Shortcut* a = findShortcut(shortcuts, "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "Ctrl+A" })); + EXPECT_EQ(a->scope, "TEST"); + + const Shortcut* d = findShortcut(shortcuts, "test://d"); + ASSERT_NE(d, nullptr); + EXPECT_EQ(d->scope, "OTHER"); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, AppliesPlatformDiff) +{ + //! [GIVEN] The platform default shortcuts file is a diff from the base one + ON_CALL(*m_configuration, defaultShortcutsName()) + .WillByDefault(Return("shortcuts_mac")); + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] The overridden shortcut is changed, the rest are intact + const ShortcutList& shortcuts = m_register->shortcuts(); + EXPECT_EQ(shortcuts.size(), 4u); + + const Shortcut* b = findShortcut(shortcuts, "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Alt+B" })); + + const Shortcut* a = findShortcut(shortcuts, "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "Ctrl+A" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, AppliesPresetDiff) +{ + //! [GIVEN] The azerty preset is selected + m_currentPresetName = "shortcuts_azerty"; + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] The preset diff is applied on top of the base shortcuts + const ShortcutList& shortcuts = m_register->shortcuts(); + EXPECT_EQ(shortcuts.size(), 4u); + + const Shortcut* c = findShortcut(shortcuts, "test://c"); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->sequences, seqs({ "&", "Num+1" })); + + const Shortcut* b = findShortcut(shortcuts, "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Ctrl+B" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, AppliesPresetDiffOverPlatformDiff) +{ + //! [GIVEN] The platform diff and the preset are set at the same time + ON_CALL(*m_configuration, defaultShortcutsName()) + .WillByDefault(Return("shortcuts_mac")); + + m_currentPresetName = "shortcuts_azerty"; + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] Both diffs are applied + const ShortcutList& shortcuts = m_register->shortcuts(); + + const Shortcut* b = findShortcut(shortcuts, "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Alt+B" })); + + const Shortcut* c = findShortcut(shortcuts, "test://c"); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->sequences, seqs({ "&", "Num+1" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, AppliesUserDiff) +{ + //! [GIVEN] A user diff file for the default shortcuts + writeUserFile("shortcuts", R"({ "TEST": [ { "command": "test://a", "sequences": ["X"] } ] })"); + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] The user diff is applied, the rest is default + const ShortcutList& shortcuts = m_register->shortcuts(); + EXPECT_EQ(shortcuts.size(), 4u); + + const Shortcut* a = findShortcut(shortcuts, "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "X" })); + + const Shortcut* b = findShortcut(shortcuts, "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Ctrl+B" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, UserDiffIsPerPreset) +{ + //! [GIVEN] A user diff file for the default shortcuts, but the azerty preset is selected + writeUserFile("shortcuts", R"({ "TEST": [ { "command": "test://a", "sequences": ["X"] } ] })"); + m_currentPresetName = "shortcuts_azerty"; + + //! [WHEN] The register is initialized + initRegister(); + + //! [THEN] The default user diff is not applied + const Shortcut* a = findShortcut(m_register->shortcuts(), "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "Ctrl+A" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, SetShortcutsWritesOnlyDiff) +{ + //! [GIVEN] The register with default shortcuts + initRegister(); + + //! [WHEN] One shortcut is modified + ShortcutList modified = m_register->shortcuts(); + for (Shortcut& sc : modified) { + if (sc.command == "test://a") { + sc.sequences = seqs({ "Y" }); + } + } + + ASSERT_TRUE(m_register->setShortcuts(modified)); + + //! [THEN] The user file contains only the modified shortcut + ByteArray data; + ASSERT_TRUE(io::File::readFile(userPath("shortcuts"), data)); + + JsonObject root = JsonDocument::fromJson(data).rootObject(); + EXPECT_EQ(root.keys().size(), 1u); + + JsonArray scopeArr = root.value("TEST").toArray(); + ASSERT_EQ(scopeArr.size(), 1u); + EXPECT_EQ(scopeArr.at(0).toObject().value("command").toString().toStdString(), "test://a"); + + //! [THEN] After reload the modified shortcut is restored, the rest are default + m_register->reload(); + + const ShortcutList& shortcuts = m_register->shortcuts(); + EXPECT_EQ(shortcuts.size(), 4u); + + const Shortcut* a = findShortcut(shortcuts, "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "Y" })); + + const Shortcut* b = findShortcut(shortcuts, "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Ctrl+B" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, RevertingAllChangesRemovesUserFile) +{ + //! [GIVEN] The register with a modified shortcut + initRegister(); + + ShortcutList defaultShortcuts = m_register->shortcuts(); + + ShortcutList modified = defaultShortcuts; + for (Shortcut& sc : modified) { + if (sc.command == "test://a") { + sc.sequences = seqs({ "Y" }); + } + } + + ASSERT_TRUE(m_register->setShortcuts(modified)); + ASSERT_TRUE(io::File::exists(userPath("shortcuts"))); + + //! [WHEN] The shortcut is reverted back to the default value + ASSERT_TRUE(m_register->setShortcuts(defaultShortcuts)); + + //! [THEN] The user file is removed + EXPECT_FALSE(io::File::exists(userPath("shortcuts"))); + EXPECT_EQ(m_register->shortcuts().size(), 4u); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, ClearedShortcutIsSaved) +{ + //! [GIVEN] The register with default shortcuts + initRegister(); + + //! [WHEN] One shortcut is cleared + ShortcutList modified = m_register->shortcuts(); + for (Shortcut& sc : modified) { + if (sc.command == "test://a") { + sc.sequences = {}; + } + } + + ASSERT_TRUE(m_register->setShortcuts(modified)); + + //! [THEN] The explicit clear survives reload + m_register->reload(); + + const Shortcut* a = findShortcut(m_register->shortcuts(), "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_TRUE(a->sequences.empty()); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, ResetShortcutsRemovesUserFile) +{ + //! [GIVEN] The register with a modified shortcut + initRegister(); + + ShortcutList modified = m_register->shortcuts(); + modified.front().sequences = seqs({ "Y" }); + ASSERT_TRUE(m_register->setShortcuts(modified)); + ASSERT_TRUE(io::File::exists(userPath("shortcuts"))); + + //! [WHEN] The shortcuts are reset + m_register->resetShortcuts(); + + //! [THEN] The user file is removed and the defaults are restored + EXPECT_FALSE(io::File::exists(userPath("shortcuts"))); + + const Shortcut* a = findShortcut(m_register->shortcuts(), "test://a"); + ASSERT_NE(a, nullptr); + EXPECT_EQ(a->sequences, seqs({ "Ctrl+A" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, PresetChangeReloadsShortcuts) +{ + //! [GIVEN] The register with default shortcuts + initRegister(); + + bool changedNotified = false; + m_register->shortcutsChanged().onNotify(this, [&changedNotified]() { + changedNotified = true; + }); + + //! [WHEN] The current preset is changed + changeCurrentPreset("shortcuts_azerty"); + + //! [THEN] The register is reloaded with the preset diff applied + EXPECT_TRUE(changedNotified); + + const Shortcut* c = findShortcut(m_register->shortcuts(), "test://c"); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->sequences, seqs({ "&", "Num+1" })); + + //! [WHEN] The preset is changed back + changeCurrentPreset(""); + + //! [THEN] The defaults are restored + c = findShortcut(m_register->shortcuts(), "test://c"); + ASSERT_NE(c, nullptr); + EXPECT_EQ(c->sequences, seqs({ "1", "Num+1" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, UserDiffIsWrittenForActivePreset) +{ + //! [GIVEN] The register with the azerty preset selected + m_currentPresetName = "shortcuts_azerty"; + initRegister(); + + //! [WHEN] One shortcut is modified + ShortcutList modified = m_register->shortcuts(); + for (Shortcut& sc : modified) { + if (sc.command == "test://b") { + sc.sequences = seqs({ "Z" }); + } + } + + ASSERT_TRUE(m_register->setShortcuts(modified)); + + //! [THEN] The user diff is written for the preset, not for the default shortcuts + EXPECT_TRUE(io::File::exists(userPath("shortcuts_azerty"))); + EXPECT_FALSE(io::File::exists(userPath("shortcuts"))); + + //! [THEN] After switching to the default shortcuts and back, the modification is restored + changeCurrentPreset(""); + const Shortcut* b = findShortcut(m_register->shortcuts(), "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Ctrl+B" })); + + changeCurrentPreset("shortcuts_azerty"); + b = findShortcut(m_register->shortcuts(), "test://b"); + ASSERT_NE(b, nullptr); + EXPECT_EQ(b->sequences, seqs({ "Z" })); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, IsPresetEditedReflectsUserFile) +{ + //! [GIVEN] The register with default shortcuts, no user file + initRegister(); + EXPECT_FALSE(m_register->isPresetEdited("")); + + //! [WHEN] One shortcut is modified + ShortcutList modified = m_register->shortcuts(); + modified.front().sequences = seqs({ "Y" }); + ASSERT_TRUE(m_register->setShortcuts(modified)); + + //! [THEN] The active set is edited + EXPECT_TRUE(m_register->isPresetEdited("")); + + //! [WHEN] The shortcuts are reset + m_register->resetShortcuts(); + + //! [THEN] The active set is not edited anymore + EXPECT_FALSE(m_register->isPresetEdited("")); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, BuiltinPresetsCannotBeDeleted) +{ + //! [GIVEN] The register and an edited builtin preset + writeUserFile("shortcuts_azerty", R"({ "TEST": [ { "command": "test://a", "sequences": ["X"] } ] })"); + initRegister(); + + //! [THEN] Neither the default set nor builtin presets can be deleted + EXPECT_FALSE(m_register->canDeletePreset("")); + EXPECT_FALSE(m_register->canDeletePreset("shortcuts_azerty")); + + //! [WHEN] Trying to delete a builtin preset anyway + m_register->deletePreset("shortcuts_azerty"); + + //! [THEN] Its user file is intact + EXPECT_TRUE(io::File::exists(userPath("shortcuts_azerty"))); +} + +TEST_F(Shortcuts_CommandShortcutsRegisterTests, DeletePresetRemovesUserFileAndFallsBackToDefault) +{ + //! [GIVEN] A custom preset (user file without a builtin twin) is selected + writeUserFile("my_preset", R"({ "TEST": [ { "command": "test://a", "sequences": ["X"] } ] })"); + m_currentPresetName = "my_preset"; + initRegister(); + + EXPECT_TRUE(m_register->canDeletePreset("my_preset")); + + //! [WHEN] The preset is deleted + EXPECT_CALL(*m_configuration, setCurrentShortcutsPresetName(std::string())); + m_register->deletePreset("my_preset"); + + //! [THEN] The user file is removed and the current preset is switched to default + EXPECT_FALSE(io::File::exists(userPath("my_preset"))); +} +} diff --git a/framework/shortcuts_v2/tests/mocks/shortcutsconfigurationmock.h b/framework/shortcuts_v2/tests/mocks/shortcutsconfigurationmock.h new file mode 100644 index 0000000000..5b7cc43708 --- /dev/null +++ b/framework/shortcuts_v2/tests/mocks/shortcutsconfigurationmock.h @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include + +#include "ishortcutsconfiguration.h" + +namespace muse::shortcuts { +class ShortcutsConfigurationMock : public IShortcutsConfiguration +{ +public: + MOCK_METHOD(QString, currentKeyboardLayout, (), (const, override)); + MOCK_METHOD(void, setCurrentKeyboardLayout, (const QString&), (override)); + + MOCK_METHOD(io::path_t, shortcutsUserAppDataPath, (), (const, override)); + MOCK_METHOD(io::path_t, shortcutsAppDataPath, (), (const, override)); + + MOCK_METHOD(std::string, defaultShortcutsName, (), (const, override)); + MOCK_METHOD(std::vector, availableShortcutsPresets, (), (const, override)); + + MOCK_METHOD(std::string, currentShortcutsPresetName, (), (const, override)); + MOCK_METHOD(void, setCurrentShortcutsPresetName, (const std::string&), (override)); + MOCK_METHOD(async::Channel, currentShortcutsPresetNameChanged, (), (const, override)); + + MOCK_METHOD(io::path_t, commandShortcutsUserAppDataPath, (const std::string&), (const, override)); + MOCK_METHOD(io::path_t, commandShortcutsAppDataPath, (const std::string&), (const, override)); +}; +} diff --git a/framework/shortcuts_v2/tests/testdata/shortcuts.json b/framework/shortcuts_v2/tests/testdata/shortcuts.json new file mode 100644 index 0000000000..e65904ef7d --- /dev/null +++ b/framework/shortcuts_v2/tests/testdata/shortcuts.json @@ -0,0 +1,22 @@ +{ + "TEST": [ + { + "command": "test://a", + "sequences": ["Ctrl+A"] + }, + { + "command": "test://b", + "sequences": ["Ctrl+B"] + }, + { + "command": "test://c", + "sequences": ["1", "Num+1"] + } + ], + "OTHER": [ + { + "command": "test://d", + "sequences": ["D"] + } + ] +} diff --git a/framework/shortcuts_v2/tests/testdata/shortcuts_azerty.json b/framework/shortcuts_v2/tests/testdata/shortcuts_azerty.json new file mode 100644 index 0000000000..75fe310177 --- /dev/null +++ b/framework/shortcuts_v2/tests/testdata/shortcuts_azerty.json @@ -0,0 +1,8 @@ +{ + "TEST": [ + { + "command": "test://c", + "sequences": ["&", "Num+1"] + } + ] +} diff --git a/framework/shortcuts_v2/tests/testdata/shortcuts_mac.json b/framework/shortcuts_v2/tests/testdata/shortcuts_mac.json new file mode 100644 index 0000000000..35f1c94025 --- /dev/null +++ b/framework/shortcuts_v2/tests/testdata/shortcuts_mac.json @@ -0,0 +1,8 @@ +{ + "TEST": [ + { + "command": "test://b", + "sequences": ["Alt+B"] + } + ] +} diff --git a/framework/stubs/shortcuts/shortcutsconfigurationstub.cpp b/framework/stubs/shortcuts/shortcutsconfigurationstub.cpp index bfa9aafa18..1a6d57de66 100644 --- a/framework/stubs/shortcuts/shortcutsconfigurationstub.cpp +++ b/framework/stubs/shortcuts/shortcutsconfigurationstub.cpp @@ -44,12 +44,36 @@ io::path_t ShortcutsConfigurationStub::shortcutsAppDataPath() const } #ifdef MUSE_MODULE_SHORTCUTS_V2 -io::path_t ShortcutsConfigurationStub::commandShortcutsUserAppDataPath() const +std::string ShortcutsConfigurationStub::defaultShortcutsName() const +{ + return "shortcuts"; +} + +std::vector ShortcutsConfigurationStub::availableShortcutsPresets() const +{ + return {}; +} + +std::string ShortcutsConfigurationStub::currentShortcutsPresetName() const +{ + return std::string(); +} + +void ShortcutsConfigurationStub::setCurrentShortcutsPresetName(const std::string&) +{ +} + +muse::async::Channel ShortcutsConfigurationStub::currentShortcutsPresetNameChanged() const +{ + return {}; +} + +io::path_t ShortcutsConfigurationStub::commandShortcutsUserAppDataPath(const std::string&) const { return io::path_t(); } -io::path_t ShortcutsConfigurationStub::commandShortcutsAppDataPath() const +io::path_t ShortcutsConfigurationStub::commandShortcutsAppDataPath(const std::string&) const { return io::path_t(); } diff --git a/framework/stubs/shortcuts/shortcutsconfigurationstub.h b/framework/stubs/shortcuts/shortcutsconfigurationstub.h index e5ae2c1a22..afdae6255b 100644 --- a/framework/stubs/shortcuts/shortcutsconfigurationstub.h +++ b/framework/stubs/shortcuts/shortcutsconfigurationstub.h @@ -36,8 +36,15 @@ class ShortcutsConfigurationStub : public IShortcutsConfiguration io::path_t shortcutsAppDataPath() const override; #ifdef MUSE_MODULE_SHORTCUTS_V2 - io::path_t commandShortcutsUserAppDataPath() const override; - io::path_t commandShortcutsAppDataPath() const override; + std::string defaultShortcutsName() const override; + std::vector availableShortcutsPresets() const override; + + std::string currentShortcutsPresetName() const override; + void setCurrentShortcutsPresetName(const std::string& name) override; + async::Channel currentShortcutsPresetNameChanged() const override; + + io::path_t commandShortcutsUserAppDataPath(const std::string& shortcutsName) const override; + io::path_t commandShortcutsAppDataPath(const std::string& shortcutsName) const override; #endif }; } diff --git a/framework/ui/uitypes.h b/framework/ui/uitypes.h index dc8900630d..d8f2bbde5d 100644 --- a/framework/ui/uitypes.h +++ b/framework/ui/uitypes.h @@ -27,9 +27,6 @@ #include #include #include -#include - -#include #include "view/iconcodes.h" // IWYU pragma: export #include "workspace/workspacetypes.h"