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)