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"