From 45fda676344211c35b86e628ede884ec1dcdb427 Mon Sep 17 00:00:00 2001 From: Matthew Corven Date: Fri, 17 Apr 2026 23:12:14 -0400 Subject: [PATCH 1/3] Fix custom shortcut capture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 14 +++++ core/key_simulator.py | 41 +++++++++++-- install_local.sh | 100 +++++++++++++++++++++++++++++++ ui/locale_manager.py | 12 ++-- ui/qml/KeyCaptureDialog.qml | 114 ++++++++++++++++++++++++++++++++++-- ui/qml/MousePage.qml | 21 ++++++- 6 files changed, 284 insertions(+), 18 deletions(-) create mode 100755 install_local.sh diff --git a/README.md b/README.md index 2d56c6f..3c68f2f 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,19 @@ source .venv/bin/activate # macOS / Linux pip install -r requirements.txt ``` +### Local user install + +If you want a repeatable build-and-install flow that stays under your user +account, run: + +```bash +./install_local.sh +``` + +That creates the build in `~/.local/share/Mouser`, installs a `mouser` +launcher in `~/.local/bin`, and validates the installed command before +finishing. + ### Dependencies | Package | Purpose | @@ -367,6 +380,7 @@ mouser/ ├── Mouser.bat # Quick-launch batch file ├── Mouser-mac.spec # Native macOS app-bundle spec ├── Mouser-linux.spec # Linux PyInstaller spec +├── install_local.sh # Deterministic user-local build + install script ├── build_macos_app.sh # macOS bundle build + icon/signing flow ├── .github/workflows/ │ ├── ci.yml # CI checks (compile, tests, QML lint) diff --git a/core/key_simulator.py b/core/key_simulator.py index 766369f..b5f5ff5 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,19 @@ def valid_custom_key_names(): return [] +def _pretty_custom_key_name(name): + normalized = name.strip().lower() + if normalized in {"super", "cmd", "command", "meta", "win", "windows"}: + return "Cmd" if sys.platform == "darwin" else "Win" + 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 +516,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 +649,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 +702,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 +1089,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/install_local.sh b/install_local.sh new file mode 100755 index 0000000..6d7bc7a --- /dev/null +++ b/install_local.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +APP_NAME="Mouser" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_ROOT="${MOUSER_INSTALL_ROOT:-$HOME/.local/share/Mouser}" +BIN_DIR="${MOUSER_BIN_DIR:-$HOME/.local/bin}" +VENV_DIR="$INSTALL_ROOT/venv" +DIST_DIR="$INSTALL_ROOT/dist" +LAUNCHER="$BIN_DIR/mouser" +VALIDATION_LOG="$(mktemp "${TMPDIR:-/tmp}/mouser-install.XXXXXX.log")" + +cleanup() { + rm -f "$VALIDATION_LOG" + rm -rf "$REPO_ROOT/build" "$REPO_ROOT/dist" +} + +trap cleanup EXIT + +if [[ -z "${PYTHON:-}" ]]; then + PYTHON_BIN="python3" +else + PYTHON_BIN="$PYTHON" +fi + +if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then + echo "error: $PYTHON_BIN is not available on PATH" >&2 + exit 1 +fi + +case "$(uname -s)" in + Darwin|Linux) ;; + *) + echo "error: supported on macOS and Linux only" >&2 + exit 1 + ;; +esac + +mkdir -p "$INSTALL_ROOT" "$BIN_DIR" + +if [[ ! -x "$VENV_DIR/bin/python" ]]; then + "$PYTHON_BIN" -m venv "$VENV_DIR" +fi + +VENV_PYTHON="$VENV_DIR/bin/python" + +cd "$REPO_ROOT" +"$VENV_PYTHON" -m pip install -r "$REPO_ROOT/requirements.txt" + +export PYINSTALLER_CONFIG_DIR="$INSTALL_ROOT/pyinstaller" + +case "$(uname -s)" in + Darwin) + "$VENV_PYTHON" -m PyInstaller "$REPO_ROOT/Mouser-mac.spec" --noconfirm + APP_BIN="$REPO_ROOT/dist/Mouser.app/Contents/MacOS/Mouser" + ;; + Linux) + "$VENV_PYTHON" -m PyInstaller "$REPO_ROOT/Mouser-linux.spec" --noconfirm + APP_BIN="$REPO_ROOT/dist/Mouser/Mouser" + ;; +esac + +if [[ ! -x "$APP_BIN" ]]; then + echo "error: expected build output was not created: $APP_BIN" >&2 + exit 1 +fi + +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +if [[ "$(uname -s)" == "Darwin" ]]; then + cp -R "$REPO_ROOT/dist/Mouser.app" "$DIST_DIR/" + APP_BIN="$DIST_DIR/Mouser.app/Contents/MacOS/Mouser" +else + cp -R "$REPO_ROOT/dist/Mouser" "$DIST_DIR/" + APP_BIN="$DIST_DIR/Mouser/Mouser" +fi + +cat > "$LAUNCHER" <"$VALIDATION_LOG" 2>&1; then + if ! grep -q "Invalid --hid-backend setting" "$VALIDATION_LOG"; then + echo "error: installed command did not validate as expected" >&2 + cat "$VALIDATION_LOG" >&2 + exit 1 + fi +else + echo "error: expected validation command to fail" >&2 + exit 1 +fi + +echo "Installed $APP_NAME to: $DIST_DIR" +echo "Launcher: $LAUNCHER" +echo "Validation: passed" diff --git a/ui/locale_manager.py b/ui/locale_manager.py index 7dd1cd8..8c6b64a 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. cmd+shift+f5 or ctrl+shift+f5", + "key_capture.valid_keys": "Valid keys: ctrl/control, shift, alt/option/opt, super/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\uff1acmd+shift+f5 \u6216 ctrl+shift+f5", + "key_capture.valid_keys": "\u6709\u6548\u6309\u952e\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super/cmd/command/meta/win/windows\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\uff1acmd+shift+f5 \u6216 ctrl+shift+f5", + "key_capture.valid_keys": "\u6709\u6548\u6309\u9375\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super/cmd/command/meta/win/windows\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..fdfbd1d 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,106 @@ 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 Qt.platform.os === "osx" ? "Cmd" : "Win" + 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 _eventKeyName(event) { + if (!event) return "" + var key = event.key + if (key === Qt.Key_Shift) return "shift" + if (key === Qt.Key_Control) return "ctrl" + if (key === Qt.Key_Alt) return "alt" + if (key === Qt.Key_Meta) return "super" + if (key === Qt.Key_Escape) return "esc" + if (key === Qt.Key_Tab) return "tab" + if (key === Qt.Key_Space) return "space" + if (key === Qt.Key_Return || key === Qt.Key_Enter) return "enter" + if (key === Qt.Key_Backspace) return "backspace" + if (key === Qt.Key_Delete) return "delete" + if (key === Qt.Key_Left) return "left" + if (key === Qt.Key_Right) return "right" + if (key === Qt.Key_Up) return "up" + if (key === Qt.Key_Down) return "down" + if (key === Qt.Key_Home) return "home" + if (key === Qt.Key_End) return "end" + if (key === Qt.Key_PageUp) return "pageup" + if (key === Qt.Key_PageDown) return "pagedown" + + for (var n = 1; n <= 12; n++) { + if (key === Qt["Key_F" + n]) + return "f" + n + } + + if (key >= Qt.Key_A && key <= Qt.Key_Z) + return String.fromCharCode(97 + (key - Qt.Key_A)) + if (key >= Qt.Key_0 && key <= Qt.Key_9) + return String.fromCharCode(48 + (key - Qt.Key_0)) + + if (event.text && event.text.length === 1) { + var ch = event.text.toLowerCase() + if (ch >= "a" && ch <= "z") return ch + if (ch >= "0" && ch <= "9") return ch + } + return "" + } + + function _comboFromEvent(event) { + var parts = [] + if (event.modifiers & Qt.ControlModifier) parts.push("ctrl") + if (event.modifiers & Qt.ShiftModifier) parts.push("shift") + if (event.modifiers & Qt.AltModifier) parts.push("alt") + if (event.modifiers & Qt.MetaModifier) parts.push("super") + + var keyName = _canonicalKeyName(_eventKeyName(event)) + if (keyName && parts.indexOf(keyName) < 0) + parts.push(keyName) + return parts.join("+") + } + + 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 +213,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: 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..05f6694 100644 --- a/ui/qml/MousePage.qml +++ b/ui/qml/MousePage.qml @@ -431,7 +431,26 @@ 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 parts.map(function(p) { + var lowered = (p || "").trim().toLowerCase() + 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 Qt.platform.os === "osx" ? "Cmd" : "Win" + 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) + }).join(" + ") } function isCustomAction(actionId) { From a0b1c6f2877a3bcebef9cd71426df5c6b34300ca Mon Sep 17 00:00:00 2001 From: Matthew Corven Date: Sat, 18 Apr 2026 00:03:13 -0400 Subject: [PATCH 2/3] Remove local installer from PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 14 ------- install_local.sh | 100 ----------------------------------------------- 2 files changed, 114 deletions(-) delete mode 100755 install_local.sh diff --git a/README.md b/README.md index 3c68f2f..2d56c6f 100644 --- a/README.md +++ b/README.md @@ -175,19 +175,6 @@ source .venv/bin/activate # macOS / Linux pip install -r requirements.txt ``` -### Local user install - -If you want a repeatable build-and-install flow that stays under your user -account, run: - -```bash -./install_local.sh -``` - -That creates the build in `~/.local/share/Mouser`, installs a `mouser` -launcher in `~/.local/bin`, and validates the installed command before -finishing. - ### Dependencies | Package | Purpose | @@ -380,7 +367,6 @@ mouser/ ├── Mouser.bat # Quick-launch batch file ├── Mouser-mac.spec # Native macOS app-bundle spec ├── Mouser-linux.spec # Linux PyInstaller spec -├── install_local.sh # Deterministic user-local build + install script ├── build_macos_app.sh # macOS bundle build + icon/signing flow ├── .github/workflows/ │ ├── ci.yml # CI checks (compile, tests, QML lint) diff --git a/install_local.sh b/install_local.sh deleted file mode 100755 index 6d7bc7a..0000000 --- a/install_local.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -APP_NAME="Mouser" -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_ROOT="${MOUSER_INSTALL_ROOT:-$HOME/.local/share/Mouser}" -BIN_DIR="${MOUSER_BIN_DIR:-$HOME/.local/bin}" -VENV_DIR="$INSTALL_ROOT/venv" -DIST_DIR="$INSTALL_ROOT/dist" -LAUNCHER="$BIN_DIR/mouser" -VALIDATION_LOG="$(mktemp "${TMPDIR:-/tmp}/mouser-install.XXXXXX.log")" - -cleanup() { - rm -f "$VALIDATION_LOG" - rm -rf "$REPO_ROOT/build" "$REPO_ROOT/dist" -} - -trap cleanup EXIT - -if [[ -z "${PYTHON:-}" ]]; then - PYTHON_BIN="python3" -else - PYTHON_BIN="$PYTHON" -fi - -if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then - echo "error: $PYTHON_BIN is not available on PATH" >&2 - exit 1 -fi - -case "$(uname -s)" in - Darwin|Linux) ;; - *) - echo "error: supported on macOS and Linux only" >&2 - exit 1 - ;; -esac - -mkdir -p "$INSTALL_ROOT" "$BIN_DIR" - -if [[ ! -x "$VENV_DIR/bin/python" ]]; then - "$PYTHON_BIN" -m venv "$VENV_DIR" -fi - -VENV_PYTHON="$VENV_DIR/bin/python" - -cd "$REPO_ROOT" -"$VENV_PYTHON" -m pip install -r "$REPO_ROOT/requirements.txt" - -export PYINSTALLER_CONFIG_DIR="$INSTALL_ROOT/pyinstaller" - -case "$(uname -s)" in - Darwin) - "$VENV_PYTHON" -m PyInstaller "$REPO_ROOT/Mouser-mac.spec" --noconfirm - APP_BIN="$REPO_ROOT/dist/Mouser.app/Contents/MacOS/Mouser" - ;; - Linux) - "$VENV_PYTHON" -m PyInstaller "$REPO_ROOT/Mouser-linux.spec" --noconfirm - APP_BIN="$REPO_ROOT/dist/Mouser/Mouser" - ;; -esac - -if [[ ! -x "$APP_BIN" ]]; then - echo "error: expected build output was not created: $APP_BIN" >&2 - exit 1 -fi - -rm -rf "$DIST_DIR" -mkdir -p "$DIST_DIR" - -if [[ "$(uname -s)" == "Darwin" ]]; then - cp -R "$REPO_ROOT/dist/Mouser.app" "$DIST_DIR/" - APP_BIN="$DIST_DIR/Mouser.app/Contents/MacOS/Mouser" -else - cp -R "$REPO_ROOT/dist/Mouser" "$DIST_DIR/" - APP_BIN="$DIST_DIR/Mouser/Mouser" -fi - -cat > "$LAUNCHER" <"$VALIDATION_LOG" 2>&1; then - if ! grep -q "Invalid --hid-backend setting" "$VALIDATION_LOG"; then - echo "error: installed command did not validate as expected" >&2 - cat "$VALIDATION_LOG" >&2 - exit 1 - fi -else - echo "error: expected validation command to fail" >&2 - exit 1 -fi - -echo "Installed $APP_NAME to: $DIST_DIR" -echo "Launcher: $LAUNCHER" -echo "Validation: passed" From 772b94414953b3eec84a32869688dc6c4a71ae7d Mon Sep 17 00:00:00 2001 From: Matthew Corven Date: Sat, 18 Apr 2026 18:52:42 -0400 Subject: [PATCH 3/3] Fix macOS shortcut capture labels --- core/key_simulator.py | 29 ++++++++++- tests/test_backend.py | 58 +++++++++++++++++++++- tests/test_key_simulator.py | 64 ++++++++++++++++++++++++ ui/backend.py | 98 ++++++++++++++++++++++++++++++++++++- ui/locale_manager.py | 12 ++--- ui/qml/KeyCaptureDialog.qml | 56 ++------------------- ui/qml/MousePage.qml | 22 +-------- 7 files changed, 257 insertions(+), 82 deletions(-) diff --git a/core/key_simulator.py b/core/key_simulator.py index b5f5ff5..98b7a38 100644 --- a/core/key_simulator.py +++ b/core/key_simulator.py @@ -29,10 +29,37 @@ 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 "Cmd" if sys.platform == "darwin" else "Win" + return "Super" if normalized in {"alt", "option", "opt"}: return "Opt" if sys.platform == "darwin" else "Alt" if normalized == "ctrl": 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 8c6b64a..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. cmd+shift+f5 or ctrl+shift+f5", - "key_capture.valid_keys": "Valid keys: ctrl/control, shift, alt/option/opt, super/cmd/command/meta/win/windows, 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\uff1acmd+shift+f5 \u6216 ctrl+shift+f5", - "key_capture.valid_keys": "\u6709\u6548\u6309\u952e\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super/cmd/command/meta/win/windows\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\uff1acmd+shift+f5 \u6216 ctrl+shift+f5", - "key_capture.valid_keys": "\u6709\u6548\u6309\u9375\uff1actrl/control\u3001shift\u3001alt/option/opt\u3001super/cmd/command/meta/win/windows\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 fdfbd1d..ecfe776 100644 --- a/ui/qml/KeyCaptureDialog.qml +++ b/ui/qml/KeyCaptureDialog.qml @@ -104,7 +104,7 @@ Rectangle { lowered = "super" } if (lowered === "super") - return Qt.platform.os === "osx" ? "Cmd" : "Win" + return "Super" if (lowered === "alt") return Qt.platform.os === "osx" ? "Opt" : "Alt" if (lowered === "ctrl") @@ -116,57 +116,9 @@ Rectangle { return lowered.charAt(0).toUpperCase() + lowered.slice(1) } - function _eventKeyName(event) { - if (!event) return "" - var key = event.key - if (key === Qt.Key_Shift) return "shift" - if (key === Qt.Key_Control) return "ctrl" - if (key === Qt.Key_Alt) return "alt" - if (key === Qt.Key_Meta) return "super" - if (key === Qt.Key_Escape) return "esc" - if (key === Qt.Key_Tab) return "tab" - if (key === Qt.Key_Space) return "space" - if (key === Qt.Key_Return || key === Qt.Key_Enter) return "enter" - if (key === Qt.Key_Backspace) return "backspace" - if (key === Qt.Key_Delete) return "delete" - if (key === Qt.Key_Left) return "left" - if (key === Qt.Key_Right) return "right" - if (key === Qt.Key_Up) return "up" - if (key === Qt.Key_Down) return "down" - if (key === Qt.Key_Home) return "home" - if (key === Qt.Key_End) return "end" - if (key === Qt.Key_PageUp) return "pageup" - if (key === Qt.Key_PageDown) return "pagedown" - - for (var n = 1; n <= 12; n++) { - if (key === Qt["Key_F" + n]) - return "f" + n - } - - if (key >= Qt.Key_A && key <= Qt.Key_Z) - return String.fromCharCode(97 + (key - Qt.Key_A)) - if (key >= Qt.Key_0 && key <= Qt.Key_9) - return String.fromCharCode(48 + (key - Qt.Key_0)) - - if (event.text && event.text.length === 1) { - var ch = event.text.toLowerCase() - if (ch >= "a" && ch <= "z") return ch - if (ch >= "0" && ch <= "9") return ch - } - return "" - } - function _comboFromEvent(event) { - var parts = [] - if (event.modifiers & Qt.ControlModifier) parts.push("ctrl") - if (event.modifiers & Qt.ShiftModifier) parts.push("shift") - if (event.modifiers & Qt.AltModifier) parts.push("alt") - if (event.modifiers & Qt.MetaModifier) parts.push("super") - - var keyName = _canonicalKeyName(_eventKeyName(event)) - if (keyName && parts.indexOf(keyName) < 0) - parts.push(keyName) - return parts.join("+") + if (!event) return "" + return backend.shortcutComboFromQtEvent(event.key, event.modifiers, event.text) } function _acceptKey(event) { @@ -218,7 +170,7 @@ Rectangle { inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhNoAutoUppercase Material.accent: dialog.theme.accent Keys.priority: Keys.BeforeItem - Keys.onPressed: dialog._acceptKey(event) + Keys.onPressed: function(event) { dialog._acceptKey(event) } Keys.onEscapePressed: { dialog.cancelled(); dialog.close() } Keys.onReturnPressed: { if (dialog._valid) { diff --git a/ui/qml/MousePage.qml b/ui/qml/MousePage.qml index 05f6694..5dbf94b 100644 --- a/ui/qml/MousePage.qml +++ b/ui/qml/MousePage.qml @@ -430,27 +430,7 @@ Item { function customLabel(actionId) { if (!actionId.startsWith("custom:")) return "" - var parts = actionId.substring(7).split("+") - return parts.map(function(p) { - var lowered = (p || "").trim().toLowerCase() - 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 Qt.platform.os === "osx" ? "Cmd" : "Win" - 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) - }).join(" + ") + return backend.actionLabelFor(actionId) } function isCustomAction(actionId) {