diff --git a/core/key_simulator.py b/core/key_simulator.py index 766369f..98b7a38 100644 --- a/core/key_simulator.py +++ b/core/key_simulator.py @@ -18,7 +18,7 @@ def custom_action_label(action_id): if not action_id.startswith("custom:"): return action_id parts = action_id[7:].split("+") - return " + ".join(p.capitalize() for p in parts) + return " + ".join(_pretty_custom_key_name(p) for p in parts) def valid_custom_key_names(): @@ -29,6 +29,46 @@ def valid_custom_key_names(): return [] +def normalize_captured_shortcut_parts(modifier_names, key_name="", platform_name=None): + """Normalize captured modifier/key names into stored shortcut syntax.""" + platform_name = platform_name or sys.platform + + def _normalize(name): + lowered = (name or "").strip().lower() + if not lowered: + return "" + if platform_name == "darwin": + if lowered == "ctrl": + return "super" + if lowered == "super": + return "ctrl" + return lowered + + parts = [] + for name in modifier_names: + normalized = _normalize(name) + if normalized and normalized not in parts: + parts.append(normalized) + + normalized_key = _normalize(key_name) + if normalized_key and normalized_key not in parts: + parts.append(normalized_key) + return "+".join(parts) + + +def _pretty_custom_key_name(name): + normalized = name.strip().lower() + if normalized in {"super", "cmd", "command", "meta", "win", "windows"}: + return "Super" + if normalized in {"alt", "option", "opt"}: + return "Opt" if sys.platform == "darwin" else "Alt" + if normalized == "ctrl": + return "Ctrl" + if normalized == "shift": + return "Shift" + return normalized.capitalize() + + def _parse_custom_combo(action_id, key_name_to_code): """Parse 'custom:ctrl+a' → list of platform key codes using given mapping.""" if not action_id.startswith("custom:"): @@ -503,8 +543,12 @@ def _send(*events): } _KEY_NAME_TO_CODE = { - "ctrl": VK_CONTROL, "shift": VK_SHIFT, "alt": VK_MENU, - "super": VK_LWIN, "tab": VK_TAB, "space": VK_SPACE, + "ctrl": VK_CONTROL, "control": VK_CONTROL, + "shift": VK_SHIFT, + "alt": VK_MENU, "option": VK_MENU, "opt": VK_MENU, + "super": VK_LWIN, "cmd": VK_LWIN, "command": VK_LWIN, + "meta": VK_LWIN, "win": VK_LWIN, "windows": VK_LWIN, + "tab": VK_TAB, "space": VK_SPACE, "enter": VK_RETURN, "esc": VK_ESCAPE, "backspace": VK_BACK, "delete": VK_DELETE, "left": VK_LEFT, "right": VK_RIGHT, "up": VK_UP, "down": VK_DOWN, @@ -632,6 +676,14 @@ def inject_scroll(flags, delta): # Mouse button simulation # CGEvent mouse button constants + _MAC_MOUSE_ACTIONS = frozenset({ + "mouse_left_click", + "mouse_right_click", + "mouse_middle_click", + "mouse_back_click", + "mouse_forward_click", + }) + _MAC_MOUSE_MAP = { "mouse_left_click": { "down_type": Quartz.kCGEventLeftMouseDown if _QUARTZ_OK else 1, @@ -677,7 +729,7 @@ def inject_mouse_up(action_id): _inject_mac_mouse(action_id, False) def is_mouse_button_action(action_id): - return action_id in _MAC_MOUSE_MAP + return action_id in _MAC_MOUSE_ACTIONS # Modifier flag bits for CGEvent _MOD_FLAGS = { @@ -1064,8 +1116,12 @@ def _execute_mac_action(action_id): } _KEY_NAME_TO_CODE = { - "ctrl": kVK_Control, "shift": kVK_Shift, "alt": kVK_Option, - "super": kVK_Command, "tab": kVK_Tab, "space": kVK_Space, + "ctrl": kVK_Control, "control": kVK_Control, + "shift": kVK_Shift, + "alt": kVK_Option, "option": kVK_Option, "opt": kVK_Option, + "super": kVK_Command, "cmd": kVK_Command, "command": kVK_Command, + "meta": kVK_Command, "win": kVK_Command, "windows": kVK_Command, + "tab": kVK_Tab, "space": kVK_Space, "enter": kVK_Return, "esc": kVK_Escape, "backspace": kVK_Delete, "delete": kVK_ForwardDelete, "left": kVK_LeftArrow, "right": kVK_RightArrow, "up": kVK_UpArrow, "down": kVK_DownArrow, diff --git a/tests/test_backend.py b/tests/test_backend.py index fad789b..f989471 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -7,11 +7,12 @@ from core.config import DEFAULT_CONFIG try: - from PySide6.QtCore import QCoreApplication + from PySide6.QtCore import QCoreApplication, Qt from ui.backend import Backend except ModuleNotFoundError: Backend = None QCoreApplication = None + Qt = None def _ensure_qapp(): @@ -253,6 +254,61 @@ def test_known_apps_include_paths_and_refresh_signal(self): self.assertEqual(apps[0]["path"], "/usr/bin/code") self.assertEqual(len(notifications), 1) + def test_shortcut_capture_keeps_ctrl_and_super_distinct_on_macos(self): + backend = self._make_backend() + + with patch("ui.backend.sys.platform", "darwin"): + self.assertEqual( + backend.shortcutComboFromQtEvent( + Qt.Key_W, + Qt.ControlModifier, + "w", + ), + "super+w", + ) + self.assertEqual( + backend.shortcutComboFromQtEvent( + Qt.Key_W, + Qt.MetaModifier, + "w", + ), + "ctrl+w", + ) + + def test_shortcut_capture_preserves_default_qt_modifier_names_off_macos(self): + backend = self._make_backend() + + with patch("ui.backend.sys.platform", "linux"): + self.assertEqual( + backend.shortcutComboFromQtEvent( + Qt.Key_W, + Qt.ControlModifier, + "w", + ), + "ctrl+w", + ) + self.assertEqual( + backend.shortcutComboFromQtEvent( + Qt.Key_W, + Qt.MetaModifier, + "w", + ), + "super+w", + ) + + def test_shortcut_capture_accepts_qt_enum_objects(self): + backend = self._make_backend() + + with patch("ui.backend.sys.platform", "darwin"): + self.assertEqual( + backend.shortcutComboFromQtEvent( + Qt.Key_W, + Qt.ControlModifier, + "w", + ), + "super+w", + ) + def test_add_profile_stores_catalog_id_for_linux_app(self): backend = self._make_backend() fake_catalog = [ diff --git a/tests/test_key_simulator.py b/tests/test_key_simulator.py index 6a372a1..bce6cfa 100644 --- a/tests/test_key_simulator.py +++ b/tests/test_key_simulator.py @@ -58,6 +58,70 @@ def test_kde_uses_ctrl_super_arrow_for_workspace_switching(self): [module.KEY_LEFTCTRL, module.KEY_LEFTMETA, module.KEY_RIGHT], ) + +class CustomShortcutCaptureTests(unittest.TestCase): + def test_custom_action_label_uses_super_as_canonical_name(self): + self.assertEqual( + key_simulator.custom_action_label("custom:cmd+w"), + "Super + W", + ) + self.assertEqual( + key_simulator.custom_action_label("custom:super+w"), + "Super + W", + ) + + def test_macos_swaps_qt_control_and_meta_semantics(self): + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["ctrl"], + "w", + platform_name="darwin", + ), + "super+w", + ) + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["super"], + "w", + platform_name="darwin", + ), + "ctrl+w", + ) + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["ctrl"], + "ctrl", + platform_name="darwin", + ), + "super", + ) + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["super"], + "super", + platform_name="darwin", + ), + "ctrl", + ) + + def test_non_macos_keeps_qt_control_and_meta_semantics(self): + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["ctrl"], + "w", + platform_name="linux", + ), + "ctrl+w", + ) + self.assertEqual( + key_simulator.normalize_captured_shortcut_parts( + ["super"], + "w", + platform_name="linux", + ), + "super+w", + ) + class MouseButtonActionTests(unittest.TestCase): """Tests for the mouse-button-to-mouse-button remapping feature.""" diff --git a/ui/backend.py b/ui/backend.py index d16f7ad..e6c09c4 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -25,7 +25,12 @@ clamp_dpi, get_buttons_for_layout, ) -from core.key_simulator import ACTIONS, custom_action_label, valid_custom_key_names +from core.key_simulator import ( + ACTIONS, + custom_action_label, + normalize_captured_shortcut_parts, + valid_custom_key_names, +) from core.startup import ( apply_login_startup, supports_login_startup, @@ -39,6 +44,93 @@ def _action_label(action_id): return ACTIONS.get(action_id, {}).get("label", "Do Nothing") +def _qt_shortcut_modifier_name(name): + """Return the raw Qt semantic name for a modifier.""" + return (name or "").strip().lower() + + +def _qt_enum_int(value): + """Coerce Qt enum and flag values from QML into plain integers.""" + if hasattr(value, "value"): + return int(value.value) + return int(value) + + +def _qt_shortcut_key_name(key, text=""): + """Translate a Qt key value into a raw Qt semantic shortcut name.""" + key = _qt_enum_int(key) + text = text or "" + + if key == _qt_enum_int(Qt.Key_Shift): + return "shift" + if key == _qt_enum_int(Qt.Key_Control): + return "ctrl" + if key == _qt_enum_int(Qt.Key_Alt): + return "alt" + if key == _qt_enum_int(Qt.Key_Meta): + return "super" + if key == _qt_enum_int(Qt.Key_Escape): + return "esc" + if key == _qt_enum_int(Qt.Key_Tab): + return "tab" + if key == _qt_enum_int(Qt.Key_Space): + return "space" + if key in (_qt_enum_int(Qt.Key_Return), _qt_enum_int(Qt.Key_Enter)): + return "enter" + if key == _qt_enum_int(Qt.Key_Backspace): + return "backspace" + if key == _qt_enum_int(Qt.Key_Delete): + return "delete" + if key == _qt_enum_int(Qt.Key_Left): + return "left" + if key == _qt_enum_int(Qt.Key_Right): + return "right" + if key == _qt_enum_int(Qt.Key_Up): + return "up" + if key == _qt_enum_int(Qt.Key_Down): + return "down" + if key == _qt_enum_int(Qt.Key_Home): + return "home" + if key == _qt_enum_int(Qt.Key_End): + return "end" + if key == _qt_enum_int(Qt.Key_PageUp): + return "pageup" + if key == _qt_enum_int(Qt.Key_PageDown): + return "pagedown" + + for n in range(1, 13): + if key == _qt_enum_int(getattr(Qt, f"Key_F{n}")): + return f"f{n}" + + if _qt_enum_int(Qt.Key_A) <= key <= _qt_enum_int(Qt.Key_Z): + return chr(ord("a") + (key - _qt_enum_int(Qt.Key_A))) + if _qt_enum_int(Qt.Key_0) <= key <= _qt_enum_int(Qt.Key_9): + return chr(ord("0") + (key - _qt_enum_int(Qt.Key_0))) + + if len(text) == 1: + lowered = text.lower() + if "a" <= lowered <= "z" or "0" <= lowered <= "9": + return lowered + return "" + + +def _qt_shortcut_combo(key, modifiers, text=""): + """Build the stored custom-shortcut string from Qt event parts.""" + modifiers = _qt_enum_int(modifiers) + parts = [] + if modifiers & _qt_enum_int(Qt.ControlModifier): + parts.append(_qt_shortcut_modifier_name("ctrl")) + if modifiers & _qt_enum_int(Qt.ShiftModifier): + parts.append("shift") + if modifiers & _qt_enum_int(Qt.AltModifier): + parts.append("alt") + if modifiers & _qt_enum_int(Qt.MetaModifier): + parts.append(_qt_shortcut_modifier_name("super")) + + key_name = _qt_shortcut_key_name(key, text) + return normalize_captured_shortcut_parts(parts, key_name) + + class Backend(QObject): """QML-exposed backend that bridges the engine and configuration.""" @@ -794,6 +886,10 @@ def getProfileMappings(self, profileName): def actionLabelFor(self, actionId): return _action_label(actionId) + @Slot(int, int, str, result=str) + def shortcutComboFromQtEvent(self, key, modifiers, text): + return _qt_shortcut_combo(key, modifiers, text) + @Slot(result=str) def dumpDeviceInfo(self): """Return JSON describing the connected device for contributor use.""" diff --git a/ui/locale_manager.py b/ui/locale_manager.py index 7dd1cd8..b35c6bf 100644 --- a/ui/locale_manager.py +++ b/ui/locale_manager.py @@ -144,8 +144,8 @@ # Key-capture dialog "key_capture.title": "Custom Shortcut", - "key_capture.placeholder": "e.g. ctrl+shift+f5", - "key_capture.valid_keys": "Valid keys: ctrl, shift, alt, super, a\u2013z, f1\u2013f12,\nspace, tab, enter, esc, left, right, up, down, delete, ...", + "key_capture.placeholder": "e.g. super+shift+f5", + "key_capture.valid_keys": "Valid keys: ctrl/control, shift, alt/option/opt, super (aliases: cmd, command, meta, win, windows), a\u2013z, f1\u2013f12,\nspace, tab, enter, esc, left, right, up, down, delete, ...", "key_capture.cancel": "Cancel", "key_capture.confirm": "Confirm", @@ -309,8 +309,8 @@ "scroll.language_desc": "\u9009\u62e9\u5e94\u7528\u7a0b\u5e8f\u7684\u663e\u793a\u8bed\u8a00\u3002", "key_capture.title": "\u81ea\u5b9a\u4e49\u5feb\u6377\u952e", - "key_capture.placeholder": "\u4f8b\u5982\uff1actrl+shift+f5", - "key_capture.valid_keys": "\u6709\u6548\u6309\u952e\uff1actrl\u3001shift\u3001alt\u3001super\u3001a\u2013z\u3001f1\u2013f12\u3001\nspace\u3001tab\u3001enter\u3001esc\u3001left\u3001right\u3001up\u3001down\u3001delete\u2026\u2026", + "key_capture.placeholder": "\u4f8b\u5982\uff1asuper+shift+f5", + "key_capture.valid_keys": "\u6709\u6548\u6309\u952e\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super\uff08\u5225\u540d\uff1acmd\u3001command\u3001meta\u3001win\u3001windows\uff09\u3001a\u2013z\u3001f1\u2013f12\u3001\nspace\u3001tab\u3001enter\u3001esc\u3001left\u3001right\u3001up\u3001down\u3001delete\u2026\u2026", "key_capture.cancel": "\u53d6\u6d88", "key_capture.confirm": "\u786e\u8ba4", @@ -469,8 +469,8 @@ "scroll.language_desc": "\u9078\u64c7\u61c9\u7528\u7a0b\u5f0f\u7684\u986f\u793a\u8a9e\u8a00\u3002", "key_capture.title": "\u81ea\u8a02\u5feb\u901f\u9375", - "key_capture.placeholder": "\u4f8b\u5982\uff1actrl+shift+f5", - "key_capture.valid_keys": "\u6709\u6548\u6309\u9375\uff1actrl\u3001shift\u3001alt\u3001super\u3001a\u2013z\u3001f1\u2013f12\u3001\nspace\u3001tab\u3001enter\u3001esc\u3001left\u3001right\u3001up\u3001down\u3001delete\u2026\u2026", + "key_capture.placeholder": "\u4f8b\u5982\uff1asuper+shift+f5", + "key_capture.valid_keys": "\u6709\u6548\u6309\u9375\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super\uff08\u5225\u540d\uff1acmd\u3001command\u3001meta\u3001win\u3001windows\uff09\u3001a\u2013z\u3001f1\u2013f12\u3001\nspace\u3001tab\u3001enter\u3001esc\u3001left\u3001right\u3001up\u3001down\u3001delete\u2026\u2026", "key_capture.cancel": "\u53d6\u6d88", "key_capture.confirm": "\u78ba\u8a8d", diff --git a/ui/qml/KeyCaptureDialog.qml b/ui/qml/KeyCaptureDialog.qml index 29fbe09..ecfe776 100644 --- a/ui/qml/KeyCaptureDialog.qml +++ b/ui/qml/KeyCaptureDialog.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Controls.Material import "Theme.js" as Theme -/* Modal dialog for entering a custom keyboard shortcut as text. +/* Modal dialog for capturing a custom keyboard shortcut. Emits captured(comboString) with e.g. "ctrl+shift+f5". */ Rectangle { @@ -71,7 +71,7 @@ Rectangle { } seen[name] = true if (modifiers.indexOf(name) < 0) hasNonModifier = true - labels.push(name.charAt(0).toUpperCase() + name.slice(1)) + labels.push(dialog._displayKeyName(name)) } if (!hasNonModifier) { _valid = false @@ -82,6 +82,58 @@ Rectangle { _preview = "\u2714 " + labels.join(" + ") } + function _canonicalKeyName(name) { + var lowered = (name || "").trim().toLowerCase() + if (!lowered) return "" + if (lowered === "control") return "ctrl" + if (lowered === "option" || lowered === "opt") return "alt" + if (lowered === "cmd" || lowered === "command" || lowered === "meta" + || lowered === "win" || lowered === "windows") { + return "super" + } + return lowered + } + + function _displayKeyName(name) { + var lowered = (name || "").trim().toLowerCase() + if (!lowered) return "" + if (lowered === "control") lowered = "ctrl" + if (lowered === "option" || lowered === "opt") lowered = "alt" + if (lowered === "cmd" || lowered === "command" || lowered === "meta" + || lowered === "win" || lowered === "windows") { + lowered = "super" + } + if (lowered === "super") + return "Super" + if (lowered === "alt") + return Qt.platform.os === "osx" ? "Opt" : "Alt" + if (lowered === "ctrl") + return "Ctrl" + if (lowered === "shift") + return "Shift" + if (lowered.length === 1) + return lowered.toUpperCase() + return lowered.charAt(0).toUpperCase() + lowered.slice(1) + } + + function _comboFromEvent(event) { + if (!event) return "" + return backend.shortcutComboFromQtEvent(event.key, event.modifiers, event.text) + } + + function _acceptKey(event) { + if (!event || event.isAutoRepeat) + return + var combo = _comboFromEvent(event) + if (!combo) + return + if (combo === "enter") + return + shortcutField.text = combo + _validate(combo) + event.accepted = true + } + // Block clicks from reaching elements underneath MouseArea { anchors.fill: parent; onClicked: {} } @@ -113,13 +165,17 @@ Rectangle { width: parent.width placeholderText: s["key_capture.placeholder"] font { family: uiState.fontFamily; pixelSize: 13 } + readOnly: true + selectByMouse: false + inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase Material.accent: dialog.theme.accent - onTextChanged: dialog._validate(text) + Keys.priority: Keys.BeforeItem + Keys.onPressed: function(event) { dialog._acceptKey(event) } Keys.onEscapePressed: { dialog.cancelled(); dialog.close() } Keys.onReturnPressed: { if (dialog._valid) { - var normalized = text.split("+").map( - function(p) { return p.trim().toLowerCase() } + var normalized = shortcutField.text.split("+").map( + function(p) { return dialog._canonicalKeyName(p) } ).join("+") dialog.captured(normalized) dialog.close() diff --git a/ui/qml/MousePage.qml b/ui/qml/MousePage.qml index 8364d97..5dbf94b 100644 --- a/ui/qml/MousePage.qml +++ b/ui/qml/MousePage.qml @@ -430,8 +430,7 @@ Item { function customLabel(actionId) { if (!actionId.startsWith("custom:")) return "" - var parts = actionId.substring(7).split("+") - return parts.map(function(p){return p.charAt(0).toUpperCase()+p.slice(1)}).join(" + ") + return backend.actionLabelFor(actionId) } function isCustomAction(actionId) {