From 2de49615db8cc6cd61e4a8b459eca320c9ec844f Mon Sep 17 00:00:00 2001 From: zhaoyingzhen Date: Tue, 30 Jun 2026 16:26:57 +0800 Subject: [PATCH] feat(shortcut): add custom shortcut CRUD and display-only support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add runtime custom shortcut CRUD (AddCustomShortcut/ModifyCustomShortcut/DeleteCustomShortcut) to KeybindingManager with input validation, conflict handling and commit-failure rollback. Add GetShortcutCommand so the control center can fetch a custom shortcut command separately. Persist runtime custom shortcuts through user-level DConfig plus a custom shortcut subpath registry. Keep Add on full save/create semantics, use updateCustomShortcut for Modify so existing entries update only changed fields, and roll back persisted state when registration or conflict persistence fails. Introduce display-only shortcut configs so modifiable shortcuts with no hotkey remain visible as "None" after their binding is taken. Keep these entries in the runtime map and expose them through ListAllShortcuts. Remove the unused checkConflictForConfig helper after conflict checks stayed on the actual LookupConflictShortcut/registerShortcut paths. 新增自定义快捷键运行时增删改(AddCustomShortcut/ModifyCustomShortcut/DeleteCustomShortcut),包含输入校验、冲突处理和提交失败回滚;新增 GetShortcutCommand,供控制中心单独获取自定义快捷键命令。 通过用户级 DConfig 和自定义快捷键 subpath 注册表持久化运行时自定义快捷键。Add 继续使用完整创建/保存语义,Modify 改用 updateCustomShortcut,仅更新已有条目的变化字段,并在注册或冲突持久化失败时回滚持久化状态。 引入 display-only 快捷键配置,使可修改但无热键的快捷键在绑定被其他快捷键占用后仍能以“无”显示。此类条目保留在运行时映射中,并通过 ListAllShortcuts 暴露。 删除未使用的 checkConflictForConfig 辅助函数,因为真实冲突检测仍走 LookupConflictShortcut/registerShortcut 路径。 Log: feat(shortcut): add custom shortcut CRUD and display-only support Change-Id: Ie59dd3cb4e2575eea15974b9454eefc0ff484bfa --- src/plugin-qt/shortcut/CMakeLists.txt | 8 + .../org.deepin.shortcut.json | 4 +- .../shortcut/configs/org.deepin.shortcut.json | 86 +++++ .../shortcut/src/config/configloader.cpp | 183 +++++++-- .../shortcut/src/config/configloader.h | 14 +- .../src/config/customshortcutstore.cpp | 223 +++++++++++ .../shortcut/src/config/customshortcutstore.h | 39 ++ .../src/core/customshortcuttransaction.cpp | 204 ++++++++++ .../src/core/customshortcuttransaction.h | 32 ++ .../shortcut/src/core/keybindingmanager.cpp | 363 +++++++++++++++--- .../shortcut/src/core/keybindingmanager.h | 30 +- .../shortcut/src/core/shortcutconfig.h | 41 +- 12 files changed, 1134 insertions(+), 93 deletions(-) create mode 100644 src/plugin-qt/shortcut/configs/org.deepin.shortcut.json create mode 100644 src/plugin-qt/shortcut/src/config/customshortcutstore.cpp create mode 100644 src/plugin-qt/shortcut/src/config/customshortcutstore.h create mode 100644 src/plugin-qt/shortcut/src/core/customshortcuttransaction.cpp create mode 100644 src/plugin-qt/shortcut/src/core/customshortcuttransaction.h diff --git a/src/plugin-qt/shortcut/CMakeLists.txt b/src/plugin-qt/shortcut/CMakeLists.txt index 8d070b7..9cb8a1b 100644 --- a/src/plugin-qt/shortcut/CMakeLists.txt +++ b/src/plugin-qt/shortcut/CMakeLists.txt @@ -27,11 +27,13 @@ set(SHORTCUT_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src) set(SHORTCUT_COMMON_SOURCES ${SHORTCUT_SRC_DIR}/core/shortcutmanager.cpp ${SHORTCUT_SRC_DIR}/core/keybindingmanager.cpp + ${SHORTCUT_SRC_DIR}/core/customshortcuttransaction.cpp ${SHORTCUT_SRC_DIR}/core/gesturemanager.cpp ${SHORTCUT_SRC_DIR}/core/qkeysequenceconverter.cpp ${SHORTCUT_SRC_DIR}/core/actionexecutor.cpp ${SHORTCUT_SRC_DIR}/core/translationmanager.cpp ${SHORTCUT_SRC_DIR}/config/configloader.cpp + ${SHORTCUT_SRC_DIR}/config/customshortcutstore.cpp ${SHORTCUT_SRC_DIR}/backend/abstractkeyhandler.h ${SHORTCUT_SRC_DIR}/backend/abstractgesturehandler.h ${SHORTCUT_SRC_DIR}/backend/specialkeyhandler.cpp @@ -113,6 +115,12 @@ foreach(SUBDIR_PATH ${SHORTCUT_CONFIG_SUBDIRS}) ) endforeach() +# install base json config +dtk_add_config_meta_files( + APPID "org.deepin.dde.keybinding" + FILES "${CMAKE_CURRENT_SOURCE_DIR}/configs/org.deepin.shortcut.json" +) + foreach(SUBDIR_PATH ${GESTURE_CONFIG_SUBDIRS}) dtk_add_config_meta_files( APPID "org.deepin.dde.keybinding" diff --git a/src/plugin-qt/shortcut/configs/org.deepin.dde.keybinding.shortcut.displayswitch/org.deepin.shortcut.json b/src/plugin-qt/shortcut/configs/org.deepin.dde.keybinding.shortcut.displayswitch/org.deepin.shortcut.json index 68d367c..e787a0b 100644 --- a/src/plugin-qt/shortcut/configs/org.deepin.dde.keybinding.shortcut.displayswitch/org.deepin.shortcut.json +++ b/src/plugin-qt/shortcut/configs/org.deepin.dde.keybinding.shortcut.displayswitch/org.deepin.shortcut.json @@ -26,12 +26,12 @@ "value": [ "Meta+P" ], - "serial": 0, + "serial": 1, "flags": [], "name": "hotkeys", "name[zh_CN]": "快捷键组合", "description": "切换显示模式快捷键组合", - "permissions": "", + "permissions": "readwrite", "visibility": "private" }, "triggerType": { diff --git a/src/plugin-qt/shortcut/configs/org.deepin.shortcut.json b/src/plugin-qt/shortcut/configs/org.deepin.shortcut.json new file mode 100644 index 0000000..2334b69 --- /dev/null +++ b/src/plugin-qt/shortcut/configs/org.deepin.shortcut.json @@ -0,0 +1,86 @@ +{ + "magic": "dsg.config.meta", + "version": "1.0", + "contents": { + "appId": { + "value": "org.deepin.dde.keybinding", + "serial": 0, + "flags": [], + "name": "appId", + "name[zh_CN]": "提供快捷键应用Id", + "description": "provide shortcut app id", + "permissions": "readwrite", + "visibility": "private" + }, + "displayName": { + "value": "", + "serial": 0, + "flags": [], + "name": "displayName", + "name[zh_CN]": "", + "description": "", + "permissions": "readwrite", + "visibility": "private" + }, + "hotkeys": { + "value": [], + "serial": 0, + "flags": [], + "name": "hotkeys", + "name[zh_CN]": "快捷键组合", + "description": "打开默认终端快捷键组合", + "permissions": "readwrite", + "visibility": "private" + }, + "triggerType": { + "value": 1, + "serial": 0, + "flags": [], + "name": "triggerType", + "name[zh_CN]": "快捷键触发动作类型", + "description": "二进制command类型", + "permissions": "readwrite", + "visibility": "private" + }, + "triggerValue": { + "value": [], + "serial": 0, + "flags": [], + "name": "triggerValue", + "name[zh_CN]": "快捷键触发动作", + "description": "", + "permissions": "readwrite", + "visibility": "private" + }, + "category": { + "value": "Custom", + "serial": 1, + "flags": [], + "name": "category", + "name[zh_CN]": "快捷键类别(Custom)", + "description": "Custom shortcut category", + "permissions": "readwrite", + "visibility": "private" + }, + "enabled": { + "value": true, + "serial": 0, + "flags": [], + "name": "enabled", + "name[zh_CN]": "使能", + "description": "是否启用快捷键", + "permissions": "readwrite", + "visibility": "private" + }, + "modifiable": { + "value": true, + "serial": 0, + "flags": [], + "name": "modifiable", + "name[zh_CN]": "能否修改快捷键", + "description": "不能修改快捷键", + "permissions": "readwrite", + "visibility": "private" + } + } +} diff --git a/src/plugin-qt/shortcut/src/config/configloader.cpp b/src/plugin-qt/shortcut/src/config/configloader.cpp index a2c8d3e..ba2fce9 100644 --- a/src/plugin-qt/shortcut/src/config/configloader.cpp +++ b/src/plugin-qt/shortcut/src/config/configloader.cpp @@ -3,7 +3,6 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include "configloader.h" -#include "core/shortcutconfig.h" #include @@ -24,6 +23,12 @@ const QString CONFIG_SUBPATH_DIR = "/usr/share/deepin/org.deepin.dde.keybinding/ DCORE_USE_NAMESPACE +static DConfig *createDConfig(const QString &name, const QString &subPath, QObject *parent) +{ + const QString normalized = CustomShortcutStore::normalizeSubPath(subPath); + return DConfig::create(APP_ID, name, QStringLiteral("/") + normalized, parent); +} + ConfigLoader::ConfigLoader(QObject *parent) : QObject(parent) { @@ -106,22 +111,36 @@ void ConfigLoader::resetHotkeys(const QStringList &ids) } } -void ConfigLoader::updateValue(const QString &id, const QString &key, const QVariant &value) +bool ConfigLoader::updateValue(const QString &id, const QString &key, const QVariant &value) { if (!m_configs.contains(id)) { qWarning() << "ConfigLoader: config not found or can not be changed:" << id << key << value; - return; + return false; } m_configs[id]->setValue(key, value); + return true; +} + +bool ConfigLoader::canUpdateValue(const QString &id) const +{ + return m_configs.contains(id); } QSet ConfigLoader::discoverSubPaths() { - // Accumulate all found subpaths QSet foundSubPaths; + foundSubPaths.unite(scanIniSubPaths(CONFIG_SUBPATH_DIR)); + foundSubPaths.unite(m_customStore.discoverSubPaths()); - QDir regDir(CONFIG_SUBPATH_DIR); + return foundSubPaths; +} + +QSet ConfigLoader::scanIniSubPaths(const QString &dirPath) +{ + QSet foundSubPaths; + + QDir regDir(dirPath); if (!regDir.exists()) { return foundSubPaths; } @@ -132,7 +151,7 @@ QSet ConfigLoader::discoverSubPaths() static const QRegularExpression reContinuation(QStringLiteral("\\\\\\s*\\n")); QStringList iniFiles = regDir.entryList(QStringList() << "*.ini", QDir::Files | QDir::NoDotAndDotDot); for (const QString &iniFile : iniFiles) { - QString fullPath = QDir(CONFIG_SUBPATH_DIR).absoluteFilePath(iniFile); + QString fullPath = regDir.absoluteFilePath(iniFile); QFile file(fullPath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { qWarning() << "ConfigLoader: Failed to open" << fullPath; @@ -182,6 +201,122 @@ QSet ConfigLoader::discoverSubPaths() return foundSubPaths; } +bool ConfigLoader::saveCustomShortcut(const KeyConfig &config) +{ + const QString subPath = CustomShortcutStore::normalizeSubPath(config.subPath); + DConfig *customDconfig = m_configs.value(subPath); + const bool existingDConfig = customDconfig; + if (!customDconfig) { + customDconfig = m_customStore.createConfig(subPath, this); + if (!customDconfig) + return false; + if (!customDconfig->isValid()) { + qWarning() << "ConfigLoader: failed to create custom shortcut DConfig:" << subPath; + delete customDconfig; + return false; + } + } + + KeyConfig storedConfig = config; + storedConfig.subPath = subPath; + if (!m_customStore.save(storedConfig, customDconfig)) { + if (!existingDConfig) { + m_customStore.reset(customDconfig, subPath); + customDconfig->deleteLater(); + } + return false; + } + + auto existing = std::find_if(m_keys.begin(), m_keys.end(), + [&](const KeyConfig &item) { return item.subPath == subPath; }); + if (existing != m_keys.end()) + *existing = storedConfig; + else + m_keys.append(storedConfig); + + m_loadedSubPaths.insert(subPath); + if (!m_configs.contains(subPath)) { + connect(customDconfig, &DConfig::valueChanged, this, [this, subPath, customDconfig](const QString &key) { + if (!customDconfig->isValid() || !m_configs.contains(subPath)) { + qWarning() << "DConfig invalid or not found:" << subPath; + return; + } + + KeyConfig updatedConfig = parseKeyConfig(customDconfig); + qDebug() << "DConfig value changed:" << subPath << key; + auto existing = std::find_if(m_keys.begin(), m_keys.end(), + [&](const KeyConfig &item) { return item.subPath == subPath; }); + if (existing != m_keys.end()) { + *existing = updatedConfig; + } else { + m_keys.append(updatedConfig); + } + emit keyConfigChanged(updatedConfig); + }); + m_configs.insert(subPath, customDconfig); + } + + return true; +} + +bool ConfigLoader::updateCustomShortcut(const KeyConfig &config) +{ + const QString subPath = CustomShortcutStore::normalizeSubPath(config.subPath); + DConfig *customDconfig = m_configs.value(subPath); + if (!customDconfig) { + qWarning() << "ConfigLoader: custom shortcut config not found for update:" << subPath; + return false; + } + + KeyConfig storedConfig = config; + storedConfig.subPath = subPath; + if (!m_customStore.updateCustomShortcutFields(storedConfig, customDconfig)) + return false; + + auto existing = std::find_if(m_keys.begin(), m_keys.end(), + [&](const KeyConfig &item) { return item.subPath == subPath; }); + if (existing != m_keys.end()) + *existing = storedConfig; + else + m_keys.append(storedConfig); + + m_loadedSubPaths.insert(subPath); + return true; +} + +bool ConfigLoader::removeCustomShortcut(const QString &subPath) +{ + const QString normalized = CustomShortcutStore::normalizeSubPath(subPath); + if (normalized.isEmpty()) { + return false; + } + + DConfig *config = m_configs.take(normalized); + if (!config || !config->isValid()) { + qWarning() << "ConfigLoader: custom shortcut config not found for removal:" << subPath; + if (config) config->deleteLater(); + return false; + } else { + disconnect(config, nullptr, this, nullptr); + } + + m_customStore.reset(config, normalized); + if (!m_customStore.removeSubPath(normalized)) { + qWarning() << "ConfigLoader: failed to remove custom shortcut subPath after reset," + << "leaving a stale ini entry:" << normalized; + } + + m_loadedSubPaths.remove(normalized); + m_keys.erase(std::remove_if(m_keys.begin(), m_keys.end(), + [&](const KeyConfig &config) { return config.subPath == normalized; }), + m_keys.end()); + + if (config) + config->deleteLater(); + + return true; +} + void ConfigLoader::loadConfig(const QString &subPath, bool newOne) { qDebug() << "Loading config from:" << subPath; @@ -199,9 +334,8 @@ void ConfigLoader::loadConfig(const QString &subPath, bool newOne) return; } - DConfig *config = DConfig::create(APP_ID, - isKey ? CONFIG_NAME_SHORTCUT : CONFIG_NAME_GESTURE, - "/" + subPath, this); + DConfig *config = createDConfig(isKey ? CONFIG_NAME_SHORTCUT : CONFIG_NAME_GESTURE, + subPath, this); if (!config->isValid()) { qWarning() << "Failed to create DConfig for" << subPath; delete config; @@ -272,20 +406,22 @@ void ConfigLoader::loadConfig(const QString &subPath, bool newOne) qDebug() << "DConfig value changed:" << subPath << key; if (isKey) { KeyConfig updatedConfig = parseKeyConfig(config); - for (auto &existing : m_keys) { - if (existing.subPath == subPath) { - existing = updatedConfig; - break; - } + auto existing = std::find_if(m_keys.begin(), m_keys.end(), + [&](const KeyConfig &item) { return item.subPath == subPath; }); + if (existing != m_keys.end()) { + *existing = updatedConfig; + } else { + m_keys.append(updatedConfig); } emit keyConfigChanged(updatedConfig); } else { GestureConfig updatedConfig = parseGestureConfig(config); - for (auto &existing : m_gestures) { - if (existing.subPath == subPath) { - existing = updatedConfig; - break; - } + auto existing = std::find_if(m_gestures.begin(), m_gestures.end(), + [&](const GestureConfig &item) { return item.subPath == subPath; }); + if (existing != m_gestures.end()) { + *existing = updatedConfig; + } else { + m_gestures.append(updatedConfig); } emit gestureConfigChanged(updatedConfig); } @@ -295,15 +431,10 @@ void ConfigLoader::loadConfig(const QString &subPath, bool newOne) } } -static QString normalizedSubpath(const QString &raw) -{ - return raw.startsWith(QLatin1Char('/')) ? raw.mid(1) : raw; -} - KeyConfig ConfigLoader::parseKeyConfig(DConfig *config) { KeyConfig keyConfig; - keyConfig.subPath = normalizedSubpath(config->subpath()); + keyConfig.subPath = CustomShortcutStore::normalizeSubPath(config->subpath()); keyConfig.appId = config->value("appId").toString(); keyConfig.displayName = config->value("displayName").toString(); keyConfig.enabled = config->value("enabled").toBool(); @@ -324,7 +455,7 @@ KeyConfig ConfigLoader::parseKeyConfig(DConfig *config) GestureConfig ConfigLoader::parseGestureConfig(DConfig *config) { GestureConfig gestureConfig; - gestureConfig.subPath = normalizedSubpath(config->subpath()); + gestureConfig.subPath = CustomShortcutStore::normalizeSubPath(config->subpath()); gestureConfig.appId = config->value("appId").toString(); gestureConfig.displayName = config->value("displayName").toString(); gestureConfig.enabled = config->value("enabled").toBool(); diff --git a/src/plugin-qt/shortcut/src/config/configloader.h b/src/plugin-qt/shortcut/src/config/configloader.h index ae5efc6..f54115a 100644 --- a/src/plugin-qt/shortcut/src/config/configloader.h +++ b/src/plugin-qt/shortcut/src/config/configloader.h @@ -4,11 +4,13 @@ #pragma once +#include "config/customshortcutstore.h" #include "core/shortcutconfig.h" #include #include #include +#include #include @@ -29,9 +31,15 @@ class ConfigLoader : public QObject void reload(); QStringList resettableHotkeyIds() const; void resetHotkeys(const QStringList &ids); - void updateValue(const QString &id, const QString &key, const QVariant &value); + bool updateValue(const QString &id, const QString &key, const QVariant &value); + bool canUpdateValue(const QString &id) const; void dumpConfigs(); + // Custom shortcut persistence (user-level DConfig + INI) + bool saveCustomShortcut(const KeyConfig &config); + bool updateCustomShortcut(const KeyConfig &config); + bool removeCustomShortcut(const QString &subPath); + QList keys() const { return m_keys; } QList gestures() const { return m_gestures; } @@ -45,7 +53,7 @@ class ConfigLoader : public QObject private: QSet discoverSubPaths(); - bool needLoad(const QString &subPath); + QSet scanIniSubPaths(const QString &dirPath); void loadConfig(const QString &subPath, bool newOne = false); KeyConfig parseKeyConfig(DConfig *config); GestureConfig parseGestureConfig(DConfig *config); @@ -54,4 +62,6 @@ class ConfigLoader : public QObject QList m_gestures; QMap m_configs; // subPath(id) -> config, key has no leading / QSet m_loadedSubPaths; // Track all loaded subPaths + + CustomShortcutStore m_customStore; }; diff --git a/src/plugin-qt/shortcut/src/config/customshortcutstore.cpp b/src/plugin-qt/shortcut/src/config/customshortcutstore.cpp new file mode 100644 index 0000000..2fdaa82 --- /dev/null +++ b/src/plugin-qt/shortcut/src/config/customshortcutstore.cpp @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "customshortcutstore.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const QString APP_ID = "org.deepin.dde.keybinding"; +const QString CONFIG_NAME_SHORTCUT = "org.deepin.shortcut"; +const QString CUSTOM_CONFIG_SUBPATH_DIR = "deepin/dde-services/shortcut"; +const QString CUSTOM_CONFIG_INI = "custom-shortcuts.ini"; + +const QStringList CustomShortcutKeys = { + QStringLiteral("appId"), + QStringLiteral("displayName"), + QStringLiteral("enabled"), + QStringLiteral("modifiable"), + QStringLiteral("triggerType"), + QStringLiteral("triggerValue"), + QStringLiteral("category"), + QStringLiteral("hotkeys") +}; + +} // namespace + +CustomShortcutStore::CustomShortcutStore() + : m_iniPath(QDir(QStandardPaths::writableLocation(QStandardPaths::ConfigLocation)) + .absoluteFilePath(CUSTOM_CONFIG_SUBPATH_DIR + QLatin1Char('/') + CUSTOM_CONFIG_INI)) +{ +} + +QSet CustomShortcutStore::discoverSubPaths() const +{ + QSet result; + const QFileInfo info(m_iniPath); + if (!info.exists()) + return result; + + const QStringList paths = subPaths(); + for (const QString &subPath : paths) + result.insert(subPath); + return result; +} + +DConfig *CustomShortcutStore::createConfig(const QString &subPath, QObject *parent) const +{ + const QString normalized = normalizeSubPath(subPath); + if (normalized.isEmpty()) { + qWarning() << "CustomShortcutStore: invalid custom shortcut subPath:" << subPath; + return nullptr; + } + return DConfig::create(APP_ID, CONFIG_NAME_SHORTCUT, QStringLiteral("/") + normalized, parent); +} + +bool CustomShortcutStore::save(const KeyConfig &config, DConfig *configObject) const +{ + if (!configObject) { + qWarning() << "CustomShortcutStore: missing DConfig object for save:" << config.subPath; + return false; + } + + const QString subPath = normalizeSubPath(config.subPath); + qDebug() << "CustomShortcutStore: saving custom shortcut" + << "subPath:" << subPath + << "appId:" << config.appId + << "displayName:" << config.displayName + << "enabled:" << config.enabled + << "modifiable:" << config.modifiable + << "triggerType:" << config.triggerType + << "triggerValue count:" << config.triggerValue.size() + << "command length:" << (config.triggerValue.isEmpty() ? 0 : config.triggerValue.first().size()) + << "category:" << config.category + << "hotkeys:" << config.hotkeys; + + { + const QSignalBlocker blocker(configObject); + configObject->setValue("appId", config.appId); + configObject->setValue("displayName", config.displayName); + configObject->setValue("enabled", config.enabled); + configObject->setValue("modifiable", config.modifiable); + configObject->setValue("triggerType", config.triggerType); + configObject->setValue("triggerValue", config.triggerValue); + configObject->setValue("category", config.category); + configObject->setValue("hotkeys", config.hotkeys); + } + + QStringList paths = subPaths(); + if (!paths.contains(subPath)) + paths.append(subPath); + return writeSubPaths(paths); +} + +bool CustomShortcutStore::updateCustomShortcutFields(const KeyConfig &config, DConfig *configObject) const +{ + if (!configObject) { + qWarning() << "CustomShortcutStore: missing DConfig object for update:" << config.subPath; + return false; + } + + const QString subPath = normalizeSubPath(config.subPath); + qDebug() << "CustomShortcutStore: updating custom shortcut fields" + << "subPath:" << subPath + << "displayName:" << config.displayName + << "triggerValue count:" << config.triggerValue.size() + << "command length:" << (config.triggerValue.isEmpty() ? 0 : config.triggerValue.first().size()) + << "hotkeys:" << config.hotkeys; + + const QSignalBlocker blocker(configObject); + const auto setIfChanged = [configObject](const QString &key, const QVariant &value) { + if (configObject->value(key) != value) + configObject->setValue(key, value); + }; + + setIfChanged(QStringLiteral("appId"), config.appId); + setIfChanged(QStringLiteral("displayName"), config.displayName); + setIfChanged(QStringLiteral("enabled"), config.enabled); + setIfChanged(QStringLiteral("modifiable"), config.modifiable); + setIfChanged(QStringLiteral("triggerType"), config.triggerType); + setIfChanged(QStringLiteral("triggerValue"), config.triggerValue); + setIfChanged(QStringLiteral("category"), config.category); + setIfChanged(QStringLiteral("hotkeys"), config.hotkeys); + return true; +} + +bool CustomShortcutStore::removeSubPath(const QString &subPath) const +{ + QStringList paths = subPaths(); + paths.removeAll(normalizeSubPath(subPath)); + return writeSubPaths(paths); +} + +void CustomShortcutStore::reset(DConfig *configObject, const QString &subPath) const +{ + if (!configObject) + return; + + const QString normalized = normalizeSubPath(subPath); + qDebug() << "CustomShortcutStore: resetting custom shortcut DConfig:" << normalized; + + QStringList resetKeys = configObject->keyList(); + resetKeys.append(CustomShortcutKeys); + resetKeys.removeDuplicates(); + + const QSignalBlocker blocker(configObject); + for (const QString &key : resetKeys) + configObject->reset(key); +} + +QString CustomShortcutStore::normalizeSubPath(const QString &raw) +{ + const QString trimmed = raw.trimmed(); + const QString normalized = trimmed.startsWith(QLatin1Char('/')) ? trimmed.mid(1) : trimmed; + if (normalized.isEmpty() + || normalized.contains(QStringLiteral("..")) + || normalized.contains(QLatin1Char('/')) + || normalized.contains(QLatin1Char('\\')) + || normalized.contains(QChar::Null)) { + qWarning() << "CustomShortcutStore: rejected unsafe subPath:" << raw; + return QString(); + } + return normalized; +} + +QStringList CustomShortcutStore::subPaths() const +{ + QSettings settings(m_iniPath, QSettings::IniFormat); + settings.beginGroup("Config"); + const QVariant subPathsVar = settings.value("SubPaths"); + QStringList paths; + if (subPathsVar.typeId() == QMetaType::QStringList) { + paths = subPathsVar.toStringList(); + } else { + paths = subPathsVar.toString().split(",", Qt::SkipEmptyParts); + } + settings.endGroup(); + + for (QString &subPath : paths) + subPath = normalizeSubPath(subPath); + paths.removeAll(QString()); + paths.removeDuplicates(); + paths.sort(); + return paths; +} + +bool CustomShortcutStore::writeSubPaths(const QStringList &subPaths) const +{ + const QFileInfo info(m_iniPath); + QDir dir(info.absolutePath()); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) { + qWarning() << "CustomShortcutStore: failed to create custom shortcut ini dir:" << info.absolutePath(); + return false; + } + + QStringList normalized; + normalized.reserve(subPaths.size()); + for (const QString &subPath : subPaths) { + const QString value = normalizeSubPath(subPath); + if (!value.isEmpty() && !normalized.contains(value)) + normalized.append(value); + } + normalized.sort(); + + QSettings settings(m_iniPath, QSettings::IniFormat); + settings.beginGroup("Config"); + settings.setValue("SubPaths", normalized); + settings.endGroup(); + settings.sync(); + + if (settings.status() != QSettings::NoError) { + qWarning() << "CustomShortcutStore: failed to write custom shortcut ini:" << m_iniPath; + return false; + } + return true; +} diff --git a/src/plugin-qt/shortcut/src/config/customshortcutstore.h b/src/plugin-qt/shortcut/src/config/customshortcutstore.h new file mode 100644 index 0000000..57920c6 --- /dev/null +++ b/src/plugin-qt/shortcut/src/config/customshortcutstore.h @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include "core/shortcutconfig.h" + +#include +#include +#include + +#include + +DCORE_USE_NAMESPACE + +class QObject; + +class CustomShortcutStore +{ +public: + CustomShortcutStore(); + + QSet discoverSubPaths() const; + DConfig *createConfig(const QString &subPath, QObject *parent) const; + + bool save(const KeyConfig &config, DConfig *configObject) const; + bool updateCustomShortcutFields(const KeyConfig &config, DConfig *configObject) const; + bool removeSubPath(const QString &subPath) const; + void reset(DConfig *configObject, const QString &subPath) const; + + static QString normalizeSubPath(const QString &raw); + +private: + QStringList subPaths() const; + bool writeSubPaths(const QStringList &subPaths) const; + + QString m_iniPath; +}; diff --git a/src/plugin-qt/shortcut/src/core/customshortcuttransaction.cpp b/src/plugin-qt/shortcut/src/core/customshortcuttransaction.cpp new file mode 100644 index 0000000..1238455 --- /dev/null +++ b/src/plugin-qt/shortcut/src/core/customshortcuttransaction.cpp @@ -0,0 +1,204 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "customshortcuttransaction.h" + +#include "backend/abstractkeyhandler.h" +#include "config/configloader.h" + +#include + +namespace { + +void appendUniqueId(QStringList &ids, const QString &id) +{ + if (!id.isEmpty() && !ids.contains(id)) + ids.append(id); +} + +} // namespace + +// Creates a transaction for one prepared custom shortcut change. +KeybindingManager::CustomShortcutTransaction::CustomShortcutTransaction( + KeybindingManager *manager, const CustomShortcutChange &change) + : m_manager(manager) + , m_change(change) +{ +} + +// Applies runtime bindings and rolls back if registration or commit fails. +bool KeybindingManager::CustomShortcutTransaction::applyRuntime() +{ + unregisterNewState(); + + const QStringList excludeIds = changedIds(); + if (!registerIfNeeded(m_change.newTarget, excludeIds)) { + qWarning() << "CustomShortcutTransaction: failed to register target shortcut" + << m_change.newTarget.getId(); + restoreRuntime(); + return false; + } + + if (m_change.hasConflict && !registerIfNeeded(m_change.newConflict, excludeIds)) { + qWarning() << "CustomShortcutTransaction: failed to register conflict shortcut" + << m_change.oldConflict.getId(); + restoreRuntime(); + return false; + } + + if (!m_manager->m_keyHandler->commitSync()) { + qWarning() << "CustomShortcutTransaction: runtime commit failed, rolling back" + << m_change.newTarget.getId(); + restoreRuntime(); + return false; + } + + return true; +} + +// Persists a newly added custom shortcut and rolls back on failure. +bool KeybindingManager::CustomShortcutTransaction::persistAdd() +{ + if (!m_manager->m_loader->saveCustomShortcut(m_change.newTarget)) { + qWarning() << "AddCustomShortcut: failed to persist custom shortcut, rolling back" + << m_change.newTarget.getId(); + restoreRuntime(); + return false; + } + + if (m_change.hasConflict) { + stageConflictMap(); + if (!m_manager->m_loader->updateValue(m_change.oldConflict.getId(), + "hotkeys", + m_change.newConflict.hotkeys)) { + qWarning() << "AddCustomShortcut: failed to persist conflict shortcut, rolling back" + << m_change.oldConflict.getId(); + restoreConflictMap(); + if (!m_manager->m_loader->removeCustomShortcut(m_change.newTarget.getId())) { + qCritical() << "AddCustomShortcut: failed to remove persisted custom shortcut during rollback" + << m_change.newTarget.getId(); + } + restoreRuntime(); + return false; + } + } + + return true; +} + +// Persists a modified custom shortcut and rolls back on failure. +bool KeybindingManager::CustomShortcutTransaction::persistModify() +{ + if (!m_manager->m_loader->updateCustomShortcut(m_change.newTarget)) { + qWarning() << "ModifyCustomShortcut: failed to persist custom shortcut, rolling back" + << m_change.newTarget.getId(); + if (!m_manager->m_loader->updateCustomShortcut(m_change.oldTarget)) { + qCritical() << "ModifyCustomShortcut: failed to restore persisted custom shortcut during rollback" + << m_change.oldTarget.getId(); + } + restoreRuntime(); + return false; + } + + if (m_change.hasConflict) { + stageConflictMap(); + if (!m_manager->m_loader->updateValue(m_change.oldConflict.getId(), + "hotkeys", + m_change.newConflict.hotkeys)) { + qWarning() << "ModifyCustomShortcut: failed to persist conflict shortcut, rolling back" + << m_change.oldConflict.getId(); + restoreConflictMap(); + if (!m_manager->m_loader->updateCustomShortcut(m_change.oldTarget)) { + qCritical() << "ModifyCustomShortcut: failed to restore persisted custom shortcut during conflict rollback" + << m_change.oldTarget.getId(); + } + restoreRuntime(); + return false; + } + } + + return true; +} + +// Publishes the final in-memory state and change signals. +void KeybindingManager::CustomShortcutTransaction::publish() +{ + if (m_change.hasConflict) { + m_manager->m_keyConfigsMap[m_change.oldConflict.getId()] = m_change.newConflict; + emit m_manager->ShortcutChanged(m_change.oldConflict.getId(), + m_manager->toShortcutInfo(m_change.newConflict)); + } + + m_manager->m_keyConfigsMap[m_change.newTarget.getId()] = m_change.newTarget; + emit m_manager->ShortcutChanged(m_change.newTarget.getId(), + m_manager->toShortcutInfo(m_change.newTarget)); +} + +// Returns ids that may conflict while this change is being applied. +QStringList KeybindingManager::CustomShortcutTransaction::changedIds() const +{ + QStringList ids; + appendUniqueId(ids, m_change.newTarget.getId()); + if (m_change.hasOldTarget) + appendUniqueId(ids, m_change.oldTarget.getId()); + if (m_change.hasConflict) + appendUniqueId(ids, m_change.oldConflict.getId()); + return ids; +} + +// Registers a shortcut only when it has hotkeys to bind. +bool KeybindingManager::CustomShortcutTransaction::registerIfNeeded( + const KeyConfig &config, const QStringList &excludeIds) const +{ + if (config.hotkeys.isEmpty()) + return true; + return m_manager->registerShortcut(config, excludeIds); +} + +// Removes runtime bindings that will be replaced by this change. +void KeybindingManager::CustomShortcutTransaction::unregisterNewState() +{ + if (m_change.hasOldTarget) + m_manager->unregisterShortcut(m_change.oldTarget.getId()); + if (m_change.hasConflict) + m_manager->unregisterShortcut(m_change.oldConflict.getId()); +} + +// Restores the old runtime bindings after a failed step. +void KeybindingManager::CustomShortcutTransaction::restoreRuntime() +{ + m_manager->unregisterShortcut(m_change.newTarget.getId()); + if (m_change.hasConflict) + m_manager->unregisterShortcut(m_change.oldConflict.getId()); + + const QStringList excludeIds = changedIds(); + bool restored = true; + if (m_change.hasConflict) + restored = registerIfNeeded(m_change.oldConflict, excludeIds) && restored; + if (m_change.hasOldTarget) + restored = registerIfNeeded(m_change.oldTarget, excludeIds) && restored; + + if (!restored) { + qCritical() << "CustomShortcutTransaction: failed to restore shortcut registration" + << m_change.newTarget.getId(); + } + if (!m_manager->m_keyHandler->commitSync()) { + qCritical() << "CustomShortcutTransaction: rollback commit failed," + << "runtime state may diverge from compositor"; + } +} + +// Mirrors the conflict change while ConfigLoader writes it. +void KeybindingManager::CustomShortcutTransaction::stageConflictMap() +{ + if (m_change.hasConflict) + m_manager->m_keyConfigsMap[m_change.oldConflict.getId()] = m_change.newConflict; +} + +// Restores the conflict shortcut in memory after a failed write. +void KeybindingManager::CustomShortcutTransaction::restoreConflictMap() +{ + if (m_change.hasConflict) + m_manager->m_keyConfigsMap[m_change.oldConflict.getId()] = m_change.oldConflict; +} diff --git a/src/plugin-qt/shortcut/src/core/customshortcuttransaction.h b/src/plugin-qt/shortcut/src/core/customshortcuttransaction.h new file mode 100644 index 0000000..0d805c3 --- /dev/null +++ b/src/plugin-qt/shortcut/src/core/customshortcuttransaction.h @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include "keybindingmanager.h" + +#include + +// Handles apply, persist, publish, and rollback for one custom shortcut change. +class KeybindingManager::CustomShortcutTransaction +{ +public: + CustomShortcutTransaction(KeybindingManager *manager, const CustomShortcutChange &change); + + bool applyRuntime(); + bool persistAdd(); + bool persistModify(); + void publish(); + +private: + QStringList changedIds() const; + bool registerIfNeeded(const KeyConfig &config, const QStringList &excludeIds) const; + void unregisterNewState(); + void restoreRuntime(); + void stageConflictMap(); + void restoreConflictMap(); + + KeybindingManager *m_manager = nullptr; + CustomShortcutChange m_change; +}; diff --git a/src/plugin-qt/shortcut/src/core/keybindingmanager.cpp b/src/plugin-qt/shortcut/src/core/keybindingmanager.cpp index 78e80d8..b5c70c4 100644 --- a/src/plugin-qt/shortcut/src/core/keybindingmanager.cpp +++ b/src/plugin-qt/shortcut/src/core/keybindingmanager.cpp @@ -3,6 +3,7 @@ // SPDX-License-Identifier: LGPL-3.0-or-later #include "keybindingmanager.h" +#include "customshortcuttransaction.h" #include "backend/abstractkeyhandler.h" #include "backend/specialkeyhandler.h" #include "config/configloader.h" @@ -14,7 +15,7 @@ #include #include #include - +#include #include namespace { @@ -48,6 +49,48 @@ static bool containsEmptyHotkey(const QStringList &hotkeys) [](const QString &h) { return h.trimmed().isEmpty(); }); } +constexpr int MaxCustomShortcutCount = 200; +constexpr int MaxCustomShortcutNameLength = 128; +constexpr int MaxCustomShortcutCommandLength = 4096; +constexpr int MaxCustomShortcutHotkeyLength = 256; + +static bool containsControlCharacter(const QString &text) +{ + for (const QChar ch : text) { + const QChar::Category category = ch.category(); + if (category == QChar::Other_Control + || category == QChar::Other_Format + || category == QChar::Other_Surrogate + || category == QChar::Other_PrivateUse + || category == QChar::Other_NotAssigned) { + return true; + } + } + return false; +} + +static bool isValidCustomShortcutName(const QString &name) +{ + return !name.isEmpty() + && name.size() <= MaxCustomShortcutNameLength + && !containsControlCharacter(name); +} + +static bool isValidCustomShortcutCommand(const QString &command) +{ + return !command.isEmpty() + && command.size() <= MaxCustomShortcutCommandLength + && !containsControlCharacter(command); +} + +static bool isValidCustomShortcutHotkey(const QString &hotkey, bool allowEmpty) +{ + if (hotkey.isEmpty()) + return allowEmpty; + return hotkey.size() <= MaxCustomShortcutHotkeyLength + && !containsControlCharacter(hotkey); +} + KeybindingManager::KeybindingManager(ConfigLoader *loader, ActionExecutor *executor, TranslationManager *translationManager, AbstractKeyHandler *keyHandler, @@ -79,7 +122,10 @@ KeybindingManager::KeybindingManager(ConfigLoader *loader, ActionExecutor *execu connect(m_loader, &ConfigLoader::keyConfigChanged, this, &KeybindingManager::onKeyConfigChanged); connect(m_loader, &ConfigLoader::keyConfigAdded, this, [this](const KeyConfig &newConfig){ - if (registerShortcut(newConfig)) { + if (newConfig.isDisplayOnly()) { + m_keyConfigsMap[newConfig.getId()] = newConfig; + emit ShortcutChanged(newConfig.getId(), toShortcutInfo(newConfig)); + } else if (registerShortcut(newConfig)) { m_keyConfigsMap[newConfig.getId()] = newConfig; m_keyHandler->commit(); } @@ -100,7 +146,9 @@ void KeybindingManager::registerAllShortcuts() // Register existing configs for (const KeyConfig &config : m_loader->keys()) { - if (registerShortcut(config)) { + if (config.isDisplayOnly()) { + m_keyConfigsMap[config.getId()] = config; + } else if (registerShortcut(config)) { m_keyConfigsMap[config.getId()] = config; } } @@ -120,6 +168,8 @@ QList KeybindingManager::ListAllShortcuts() for (const auto &config : m_keyConfigsMap) { // Only expose modifiable shortcuts — control center filters out // non-modifiable entries to avoid showing read-only items. + // Empty hotkeys are still exposed so the control center can show + // the existing row as "None" after another shortcut takes its binding. if (!config.modifiable) { continue; } @@ -211,6 +261,17 @@ ShortcutInfo KeybindingManager::GetShortcut(const QString &id) return ShortcutInfo(); } +QString KeybindingManager::GetShortcutCommand(const QString &id) +{ + const KeyConfig config = m_keyConfigsMap.value(id); + if (config.category == QLatin1String(CategoryKey::Custom) + && config.triggerType == static_cast(TriggerType::Command) + && !config.triggerValue.isEmpty()) { + return config.triggerValue.first(); + } + return QString(); +} + ShortcutInfo KeybindingManager::LookupConflictShortcut(const QString &hotkey) { const QString needle = normalizeHotkey(hotkey); @@ -330,6 +391,143 @@ bool KeybindingManager::ModifyHotkeys(const QString &id, const QStringList &newH return true; } +QString KeybindingManager::AddCustomShortcut(const QString &name, const QString &action, const QString &hotkey) +{ + const QString displayName = name.trimmed(); + const QString actionText = action.trimmed(); + const QString normalizedHotkey = normalizeHotkey(hotkey); + + if (runtimeCustomShortcutCount() >= MaxCustomShortcutCount) { + qWarning() << "AddCustomShortcut: custom shortcut count limit reached"; + return QString(); + } + + if (!isValidCustomShortcutName(displayName) + || !isValidCustomShortcutCommand(actionText) + || !isValidCustomShortcutHotkey(normalizedHotkey, false)) { + qWarning() << "AddCustomShortcut: invalid input"; + return QString(); + } + + KeyConfig config; + config.appId = QStringLiteral("org.deepin.dde.keybinding"); + config.subPath = createCustomShortcutId(); + config.keyEventFlags = KeyEventFlag::Release; + updateCustomShortcutConfigFields(config, displayName, actionText, normalizedHotkey); + + CustomShortcutChange change; + change.newTarget = config; + if (!prepareConflictShortcutChange(normalizedHotkey, change)) + return QString(); + + CustomShortcutTransaction transaction(this, change); + if (!transaction.applyRuntime()) { + qWarning() << "AddCustomShortcut: failed to apply runtime change" << config.getId(); + return QString(); + } + + if (!transaction.persistAdd()) + return QString(); + + transaction.publish(); + return config.getId(); +} + +bool KeybindingManager::ModifyCustomShortcut(const QString &id, const QString &name, + const QString &action, const QString &hotkey) +{ + if (!m_keyConfigsMap.contains(id)) { + return false; + } + + KeyConfig oldConfig = m_keyConfigsMap[id]; + if (!isRuntimeCustomShortcut(oldConfig)) { + qWarning() << "ModifyCustomShortcut: shortcut is not a runtime custom shortcut:" << id; + return false; + } + + const QString displayName = name.trimmed(); + const QString actionText = action.trimmed(); + const QString normalizedHotkey = normalizeHotkey(hotkey); + if (!isValidCustomShortcutName(displayName) + || !isValidCustomShortcutCommand(actionText) + || !isValidCustomShortcutHotkey(normalizedHotkey, true)) { + qWarning() << "ModifyCustomShortcut: invalid input" << id; + return false; + } + + KeyConfig newConfig = oldConfig; + updateCustomShortcutConfigFields(newConfig, displayName, actionText, normalizedHotkey); + + const bool hotkeysChanged = oldConfig.hotkeys != newConfig.hotkeys; + if (!hotkeysChanged) { + if (oldConfig == newConfig) + return true; + + if (!m_loader->updateCustomShortcut(newConfig)) { + qWarning() << "ModifyCustomShortcut: failed to persist custom shortcut:" << id; + return false; + } + + m_keyConfigsMap[id] = newConfig; + emit ShortcutChanged(id, toShortcutInfo(newConfig)); + return true; + } + + CustomShortcutChange change; + change.hasOldTarget = true; + change.oldTarget = oldConfig; + change.newTarget = newConfig; + if (!prepareConflictShortcutChange(normalizedHotkey, change, id)) + return false; + + CustomShortcutTransaction transaction(this, change); + if (!transaction.applyRuntime()) { + qWarning() << "ModifyCustomShortcut: failed to apply runtime change" << id; + return false; + } + + if (!transaction.persistModify()) + return false; + + transaction.publish(); + return true; +} + +bool KeybindingManager::DeleteCustomShortcut(const QString &id) +{ + if (!m_keyConfigsMap.contains(id)) { + return false; + } + + KeyConfig oldConfig = m_keyConfigsMap[id]; + if (!isRuntimeCustomShortcut(oldConfig)) { + qWarning() << "DeleteCustomShortcut: shortcut is not a runtime custom shortcut:" << id; + return false; + } + + unregisterShortcut(id); + if (!m_keyHandler->commitSync()) { + qWarning() << "DeleteCustomShortcut: commit failed, restoring" << id; + if (registerShortcut(oldConfig, QStringList{id})) { + m_keyHandler->commitSync(); + } + return false; + } + + if (!m_loader->removeCustomShortcut(id)) { + qWarning() << "DeleteCustomShortcut: failed to remove persisted custom shortcut, restoring" << id; + if (registerShortcut(oldConfig, QStringList{id})) { + m_keyHandler->commitSync(); + } + return false; + } + + m_keyConfigsMap.remove(id); + emit ShortcutRemoved(id); + return true; +} + bool KeybindingManager::SwapHotkeys(const QString &id1, const QString &id2) { if (id1 == id2) @@ -346,6 +544,12 @@ bool KeybindingManager::SwapHotkeys(const QString &id1, const QString &id2) << id1 << id2; return false; } + if (!canPersistShortcutHotkeys(config1) || !canPersistShortcutHotkeys(config2)) { + qWarning() << "SwapHotkeys: both shortcuts must have writable configs:" + << id1 << m_loader->canUpdateValue(id1) + << id2 << m_loader->canUpdateValue(id2); + return false; + } const QStringList hotkeys1 = config1.hotkeys; const QStringList hotkeys2 = config2.hotkeys; @@ -430,6 +634,12 @@ bool KeybindingManager::ReplaceHotkey(const QString &targetId, const QString &ne qWarning() << "ReplaceHotkey: conflict shortcut not modifiable or enabled:" << conflictId; return false; } + if (!canPersistShortcutHotkeys(targetConfig) || !canPersistShortcutHotkeys(conflictConfig)) { + qWarning() << "ReplaceHotkey: target and conflict shortcuts must have writable configs:" + << targetId << m_loader->canUpdateValue(targetId) + << conflictId << m_loader->canUpdateValue(conflictId); + return false; + } const QString normalized = normalizeHotkey(newHotkey); if (normalized.trimmed().isEmpty()) { @@ -574,6 +784,8 @@ void KeybindingManager::onKeyConfigChanged(const KeyConfig &config) if (!config.enabled) { // new one, but disabled, skip return; + } else if (config.isDisplayOnly()) { + m_keyConfigsMap[config.getId()] = config; } else { // new one, enable if (registerShortcut(config)) { @@ -598,7 +810,9 @@ void KeybindingManager::onKeyConfigChanged(const KeyConfig &config) // update m_keyHandler->unregisterKey(config.getId()); m_keyConfigsMap.remove(config.getId()); - if (registerShortcut(config)) { + if (config.isDisplayOnly()) { + m_keyConfigsMap[config.getId()] = config; + } else if (registerShortcut(config)) { m_keyConfigsMap[config.getId()] = config; } m_keyHandler->commit(); @@ -641,24 +855,18 @@ void KeybindingManager::onKeyActivated(const QString &shortcutId) } } -bool KeybindingManager::registerShortcut(const KeyConfig &config) +bool KeybindingManager::registerShortcut(const KeyConfig &config, const QStringList &excludeIds) { - if (!config.isValid()) { - qWarning() << "Shortcut is disabled or invalid, skipping registration:" - << "Enabled:" << config.enabled - << "AppId:" << config.appId - << "DisplayName:" << config.displayName - << "hotkeys:" << config.hotkeys; + if (!config.canRegister()) { + qWarning() << "Shortcut can not be registered, skipping:" + << "Enabled:" << config.enabled + << "AppId:" << config.appId + << "DisplayName:" << config.displayName + << "hotkeys:" << config.hotkeys; return false; } - if (config.hotkeys.isEmpty()) { - qInfo() << "Shortcut has no hotkeys, keeping it visible without registration:" - << config.getId(); - return true; - } - - if (m_keyConfigsMap.contains(config.getId())) { + if (m_keyConfigsMap.contains(config.getId()) && !excludeIds.contains(config.getId())) { qWarning() << "Shortcut conflict detected during init: has same appId and displayName" << "hotkeys:" << config.hotkeys << "Conflicts with:" << m_keyConfigsMap[config.getId()].hotkeys @@ -685,7 +893,7 @@ bool KeybindingManager::registerShortcut(const KeyConfig &config) // Check for conflicts before registering for (const QString &hotkey : normalHotkeys) { auto shortcutInfo = LookupConflictShortcut(hotkey); - if (!shortcutInfo.id.isEmpty()) { + if (!shortcutInfo.id.isEmpty() && !excludeIds.contains(shortcutInfo.id)) { qWarning() << "Shortcut conflict detected during init:" << "Config appId:" << config.appId << "Config displayName:" << config.displayName @@ -718,34 +926,55 @@ bool KeybindingManager::registerShortcut(const KeyConfig &config) return registered; } -QString KeybindingManager::checkConflictForConfig(const KeyConfig &config, const QString &excludeId) +void KeybindingManager::unregisterShortcut(const QString &id) { - QString currentId = config.getId(); - - // Check each hotkey in the config - for (const QString &hotkey : config.hotkeys) { - // Search through all registered shortcuts - for (const KeyConfig &existingConfig : m_keyConfigsMap) { - QString existingId = existingConfig.getId(); - - // Skip if this is the config we're excluding (self-check) - if (!excludeId.isEmpty() && existingId == excludeId) { - continue; - } - - // Skip if not enabled - if (!existingConfig.enabled) { - continue; - } - - // Check if hotkey conflicts - if (existingConfig.hotkeys.contains(hotkey)) { - return existingId; // Return the conflicting shortcut ID - } - } + m_keyHandler->unregisterKey(id); + m_specialKeyHandler->unregisterKey(id); +} + +bool KeybindingManager::isRuntimeCustomShortcut(const KeyConfig &config) const +{ + return config.category == QLatin1String(CategoryKey::Custom) + && config.modifiable + && config.subPath.startsWith(QStringLiteral("org.deepin.dde.keybinding.shortcut.custom.")); +} + +bool KeybindingManager::canPersistShortcutHotkeys(const KeyConfig &config) const +{ + return config.modifiable && m_loader->canUpdateValue(config.getId()); +} + +int KeybindingManager::runtimeCustomShortcutCount() const +{ + int count = 0; + for (const KeyConfig &config : m_keyConfigsMap) { + if (isRuntimeCustomShortcut(config)) + ++count; } - - return QString(); // No conflict + return count; +} + +QString KeybindingManager::createCustomShortcutId() const +{ + QString id; + do { + id = QStringLiteral("org.deepin.dde.keybinding.shortcut.custom.") + + QUuid::createUuid().toString(QUuid::WithoutBraces); + } while (m_keyConfigsMap.contains(id)); + return id; +} + +void KeybindingManager::updateCustomShortcutConfigFields(KeyConfig &config, const QString &displayName, + const QString &commandText, + const QString &normalizedHotkey) const +{ + config.displayName = displayName; + config.category = QString::fromLatin1(CategoryKey::Custom); + config.enabled = true; + config.modifiable = true; + config.triggerType = static_cast(TriggerType::Command); + config.triggerValue = QStringList{commandText}; + config.hotkeys = normalizedHotkey.isEmpty() ? QStringList() : QStringList{normalizedHotkey}; } ShortcutInfo KeybindingManager::toShortcutInfo(const KeyConfig &config) @@ -814,3 +1043,47 @@ void KeybindingManager::SetCapsLockState(uint state) } } } + +bool KeybindingManager::prepareConflictShortcutChange(const QString &hotkey, CustomShortcutChange &change, + const QString &selfId) +{ + change.hasConflict = false; + change.oldConflict = KeyConfig(); + change.newConflict = KeyConfig(); + + const QString normalizedHotkey = normalizeHotkey(hotkey); + if (normalizedHotkey.isEmpty()) + return true; + + ShortcutInfo conflictInfo = LookupConflictShortcut(normalizedHotkey); + if (conflictInfo.id.isEmpty() || conflictInfo.id == selfId) { + return true; + } + + if (!m_keyConfigsMap.contains(conflictInfo.id)) { + qWarning() << "prepareConflictShortcutChange: conflict shortcut is missing:" << conflictInfo.id; + return false; + } + + KeyConfig conflictConfig = m_keyConfigsMap.value(conflictInfo.id); + if (!canPersistShortcutHotkeys(conflictConfig)) { + qWarning() << "prepareConflictShortcutChange: conflict shortcut can not be replaced:" + << conflictInfo.id + << "modifiable:" << conflictConfig.modifiable + << "has writable config:" << m_loader->canUpdateValue(conflictInfo.id); + return false; + } + + KeyConfig newConflictConfig = conflictConfig; + if (!newConflictConfig.hotkeys.removeOne(normalizedHotkey)) { + qWarning() << "prepareConflictShortcutChange: hotkey not found in conflict shortcut:" + << normalizedHotkey << conflictInfo.id; + return false; + } + + change.hasConflict = true; + change.oldConflict = conflictConfig; + change.newConflict = newConflictConfig; + + return true; +} diff --git a/src/plugin-qt/shortcut/src/core/keybindingmanager.h b/src/plugin-qt/shortcut/src/core/keybindingmanager.h index 8c96339..20f9e96 100644 --- a/src/plugin-qt/shortcut/src/core/keybindingmanager.h +++ b/src/plugin-qt/shortcut/src/core/keybindingmanager.h @@ -76,11 +76,15 @@ public slots: Q_SCRIPTABLE QList ListCategories(); Q_SCRIPTABLE ShortcutInfo GetShortcut(const QString &id); + Q_SCRIPTABLE QString GetShortcutCommand(const QString &id); Q_SCRIPTABLE ShortcutInfo LookupConflictShortcut(const QString &hotkey); Q_SCRIPTABLE QList SearchShortcuts(const QString &keyword); Q_SCRIPTABLE bool ModifyHotkeys(const QString &id, const QStringList &newHotkeys); Q_SCRIPTABLE bool Disable(const QString &id); + Q_SCRIPTABLE QString AddCustomShortcut(const QString &name, const QString &action, const QString &hotkey); + Q_SCRIPTABLE bool ModifyCustomShortcut(const QString &id, const QString &name, const QString &action, const QString &hotkey); + Q_SCRIPTABLE bool DeleteCustomShortcut(const QString &id); // Atomically swap the hotkeys of two shortcuts in a single compositor commit. Q_SCRIPTABLE bool SwapHotkeys(const QString &id1, const QString &id2); @@ -112,12 +116,33 @@ private slots: ShortcutInfo toShortcutInfo(const KeyConfig &config); private: - bool registerShortcut(const KeyConfig &config); - QString checkConflictForConfig(const KeyConfig &config, const QString &excludeId = QString()); + bool registerShortcut(const KeyConfig &config, const QStringList &excludeIds = QStringList()); + void unregisterShortcut(const QString &id); void rollbackRegistration(const QString &id1, const QString &id2, KeyConfig &config1, KeyConfig &config2, const QStringList &hotkeys1, const QStringList &hotkeys2); QString localizedNoHotkeyText() const; + bool isRuntimeCustomShortcut(const KeyConfig &config) const; + bool canPersistShortcutHotkeys(const KeyConfig &config) const; + int runtimeCustomShortcutCount() const; + QString createCustomShortcutId() const; + void updateCustomShortcutConfigFields(KeyConfig &config, const QString &displayName, + const QString &commandText, + const QString &normalizedHotkey) const; + + struct CustomShortcutChange { + bool hasOldTarget = false; + KeyConfig oldTarget; + KeyConfig newTarget; + + bool hasConflict = false; + KeyConfig oldConflict; + KeyConfig newConflict; + }; + class CustomShortcutTransaction; + + bool prepareConflictShortcutChange(const QString &hotkey, CustomShortcutChange &change, + const QString &selfId = QString()); ConfigLoader *m_loader; @@ -161,4 +186,3 @@ inline const QDBusArgument &operator>>(const QDBusArgument &argument, CategoryIn argument.endStructure(); return argument; } - diff --git a/src/plugin-qt/shortcut/src/core/shortcutconfig.h b/src/plugin-qt/shortcut/src/core/shortcutconfig.h index 2535e6b..d51f03c 100644 --- a/src/plugin-qt/shortcut/src/core/shortcutconfig.h +++ b/src/plugin-qt/shortcut/src/core/shortcutconfig.h @@ -42,6 +42,17 @@ struct BaseConfig QStringList triggerValue; // Command args, AppID, or Action Enum Value (as string list) QString getId() const { return subPath; } + bool operator==(const BaseConfig &other) const { + return appId == other.appId + && subPath == other.subPath + && displayName == other.displayName + && category == other.category + && enabled == other.enabled + && modifiable == other.modifiable + && triggerType == other.triggerType + && triggerValue == other.triggerValue; + } + bool operator!=(const BaseConfig &other) const { return !(*this == other); } }; // Keyboard shortcut configuration @@ -53,23 +64,16 @@ struct KeyConfig : public BaseConfig KeyConfig() : keyEventFlags(KeyEventFlag::Release) {} // Default to release bool isValid() const { return enabled && !appId.isEmpty() && !displayName.isEmpty(); } - + bool canRegister() const { return isValid() && !hotkeys.isEmpty(); } + // Valid enough to display (has identity) but carries no key binding; shown + // as "None" after another shortcut takes its binding. + bool isDisplayOnly() const { return enabled && modifiable && !appId.isEmpty() && !displayName.isEmpty() && hotkeys.isEmpty(); } bool operator==(const KeyConfig &other) const { - return appId == other.appId && - subPath == other.subPath && - displayName == other.displayName && - category == other.category && - enabled == other.enabled && - modifiable == other.modifiable && - triggerType == other.triggerType && - triggerValue == other.triggerValue && - hotkeys == other.hotkeys && - keyEventFlags == other.keyEventFlags; - } - - bool operator!=(const KeyConfig &other) const { - return !(*this == other); + return static_cast(*this) == static_cast(other) + && hotkeys == other.hotkeys + && keyEventFlags == other.keyEventFlags; } + bool operator!=(const KeyConfig &other) const { return !(*this == other); } }; enum class GestureType { @@ -85,6 +89,13 @@ struct GestureConfig : public BaseConfig int direction; // 0: None, 1: Down, 2: Left, 3: Up, 4: Right bool isValid() const { return enabled && !appId.isEmpty() && !displayName.isEmpty() && gestureType > 0 && fingerCount > 0; } + bool operator==(const GestureConfig &other) const { + return static_cast(*this) == static_cast(other) + && gestureType == other.gestureType + && fingerCount == other.fingerCount + && direction == other.direction; + } + bool operator!=(const GestureConfig &other) const { return !(*this == other); } }; Q_DECLARE_METATYPE(KeyConfig)