Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions core/key_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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:"):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 57 additions & 1 deletion tests/test_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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 = [
Expand Down
64 changes: 64 additions & 0 deletions tests/test_key_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
98 changes: 97 additions & 1 deletion ui/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""

Expand Down Expand Up @@ -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."""
Expand Down
Loading