diff --git a/src/app_model/registries/_keybindings_reg.py b/src/app_model/registries/_keybindings_reg.py index 1e04dc3..25c4fb4 100644 --- a/src/app_model/registries/_keybindings_reg.py +++ b/src/app_model/registries/_keybindings_reg.py @@ -6,8 +6,6 @@ from psygnal import Signal -from app_model.types import KeyBinding - if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping from typing import TypeVar @@ -16,6 +14,7 @@ from app_model.types import ( Action, DisposeCallable, + KeyBinding, KeyBindingRule, KeyBindingSource, ) @@ -150,36 +149,34 @@ def register_keybinding_rule( Optional[DisposeCallable] A callable that can be used to unregister the keybinding """ - if plat_keybinding := rule._bind_to_current_platform(): - # list registry - keybinding = KeyBinding.validate(plat_keybinding) - if self._filter_keybinding: - msg = self._filter_keybinding(keybinding) - if msg: - raise ValueError(f"{keybinding}: {msg}") - entry = _RegisteredKeyBinding( - keybinding=keybinding, - command_id=id, - weight=rule.weight, - when=rule.when, - source=rule.source, - ) + if (keybinding := rule.to_keybinding()) is None: + return None # pragma: no cover + + if self._filter_keybinding and (msg := self._filter_keybinding(keybinding)): + raise ValueError(f"{keybinding}: {msg}") + + entry = _RegisteredKeyBinding( + keybinding=keybinding, + command_id=id, + weight=rule.weight, + when=rule.when, + source=rule.source, + ) - # inverse map registry - entries = self._keymap[keybinding.to_int()] - insort_left(entries, entry) + # inverse map registry + entries = self._keymap[keybinding.to_int()] + insort_left(entries, entry) - self.registered.emit() + self.registered.emit() - def _dispose() -> None: - # inverse map registry remove - entries.remove(entry) - self.unregistered.emit() - if len(entries) == 0: - del self._keymap[keybinding.to_int()] + def _dispose() -> None: + # inverse map registry remove + entries.remove(entry) + self.unregistered.emit() + if len(entries) == 0: + del self._keymap[keybinding.to_int()] - return _dispose - return None # pragma: no cover + return _dispose def __iter__(self) -> Iterator[_RegisteredKeyBinding]: yield from self._keybindings diff --git a/src/app_model/types/_constants.py b/src/app_model/types/_constants.py index d592967..7fe23b3 100644 --- a/src/app_model/types/_constants.py +++ b/src/app_model/types/_constants.py @@ -26,18 +26,18 @@ def current() -> "OperatingSystem": @property def is_windows(self) -> bool: - """Returns True if the current operating system is Windows.""" - return _CURRENT == OperatingSystem.WINDOWS + """Returns True if this enum instance is Windows.""" + return self == OperatingSystem.WINDOWS # pragma: no cover @property def is_linux(self) -> bool: - """Returns True if the current operating system is Linux.""" - return _CURRENT == OperatingSystem.LINUX + """Returns True if this enum instance is Linux.""" + return self == OperatingSystem.LINUX # pragma: no cover @property def is_mac(self) -> bool: - """Returns True if the current operating system is MacOS.""" - return _CURRENT == OperatingSystem.MACOS + """Returns True if this enum instance is MacOS.""" + return self == OperatingSystem.MACOS _CURRENT = OperatingSystem.UNKNOWN diff --git a/src/app_model/types/_keybinding_rule.py b/src/app_model/types/_keybinding_rule.py index a184f79..2a58acf 100644 --- a/src/app_model/types/_keybinding_rule.py +++ b/src/app_model/types/_keybinding_rule.py @@ -3,6 +3,7 @@ from pydantic_compat import PYDANTIC2, Field, model_validator from app_model import expressions +from app_model.types._keys._keybindings import KeyBinding from ._base import _BaseModel from ._constants import KeyBindingSource, OperatingSystem @@ -11,11 +12,6 @@ KeyEncoding = Union[int, str] M = TypeVar("M") -_OS = OperatingSystem.current() -_WIN = _OS.is_windows -_MAC = _OS.is_mac -_LINUX = _OS.is_linux - class KeyBindingRule(_BaseModel): """Data representing a keybinding and when it should be active. @@ -54,14 +50,31 @@ class KeyBindingRule(_BaseModel): description="Who registered the keybinding. Used to sort keybindings.", ) - def _bind_to_current_platform(self) -> Optional[KeyEncoding]: - if _WIN and self.win: - return self.win - if _MAC and self.mac: - return self.mac - if _LINUX and self.linux: - return self.linux - return self.primary + def to_keybinding( + self, os: Optional[OperatingSystem] = None + ) -> "Optional[KeyBinding]": + """Return a keybinding for the given OS, or current OS if not specified.""" + if (enc := self.for_os(os)) is not None: + if isinstance(enc, int): + return KeyBinding.from_int(enc, os=os) + elif isinstance(enc, str): + return KeyBinding.from_str(enc) + else: + raise TypeError("invalid keybinding") # pragma: no cover + raise ValueError("No keybinding for platform") # pragma: no cover + + def for_os(self, os: Optional[OperatingSystem] = None) -> Optional[KeyEncoding]: + """Select the encoding for the given OS, or current OS if not specified.""" + if os is None: + os = OperatingSystem.current() + enc = { + OperatingSystem.WINDOWS: self.win, + OperatingSystem.MACOS: self.mac, + OperatingSystem.LINUX: self.linux, + }[os] + if enc is None: + return self.primary + return enc # These methods are here to make KeyBindingRule work as a field # there are better ways to do this now with pydantic v2... but it still diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index d0f6ecc..78a579b 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -736,10 +736,14 @@ class KeyMod(IntFlag): """A Flag indicating keyboard modifiers.""" NONE = 0 + CtrlCmd = 1 << 11 # command on a mac, control on windows Shift = 1 << 10 # shift key Alt = 1 << 9 # alt option - WinCtrl = 1 << 8 # meta key on windows, ctrl key on mac + WinCtrl = 1 << 8 # meta key on windows, control key on mac + + Ctrl = 1 << 12 # control key, regardless of OS + Meta = 1 << 13 # command key on a mac, meta key on windows @overload # type: ignore def __or__(self, other: "KeyMod") -> "KeyMod": ... diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index c958046..3d16545 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -80,16 +80,20 @@ def from_int( cls, key_int: int, os: Optional[OperatingSystem] = None ) -> "SimpleKeyBinding": """Create a SimpleKeyBinding from an integer.""" - ctrl_cmd = bool(key_int & KeyMod.CtrlCmd) - win_ctrl = bool(key_int & KeyMod.WinCtrl) + if os is None: + os = OperatingSystem.current() + shift = bool(key_int & KeyMod.Shift) alt = bool(key_int & KeyMod.Alt) - - os = OperatingSystem.current() if os is None else os - ctrl = win_ctrl if os.is_mac else ctrl_cmd - meta = ctrl_cmd if os.is_mac else win_ctrl key = key_int & 0x000000FF # keycode mask + if os.is_mac: + ctrl = bool(key_int & KeyMod.WinCtrl) or bool(key_int & KeyMod.Ctrl) + meta = bool(key_int & KeyMod.CtrlCmd) or bool(key_int & KeyMod.Meta) + else: + ctrl = bool(key_int & KeyMod.CtrlCmd) or bool(key_int & KeyMod.Ctrl) + meta = bool(key_int & KeyMod.WinCtrl) or bool(key_int & KeyMod.Meta) + return cls(ctrl=ctrl, shift=shift, alt=alt, meta=meta, key=key) def __int__(self) -> int: @@ -102,14 +106,15 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: """Convert this SimpleKeyBinding to an integer representation.""" os = OperatingSystem.current() if os is None else os mods: KeyMod = KeyMod.NONE + is_mac = os == OperatingSystem.MACOS if self.ctrl: - mods |= KeyMod.WinCtrl if os.is_mac else KeyMod.CtrlCmd + mods |= KeyMod.WinCtrl if is_mac else KeyMod.CtrlCmd if self.shift: mods |= KeyMod.Shift if self.alt: mods |= KeyMod.Alt if self.meta: - mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl + mods |= KeyMod.CtrlCmd if is_mac else KeyMod.WinCtrl return mods | (self.key or 0) def _mods2keycodes(self) -> list[KeyCode]: diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index 1223db6..6e48a77 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -195,3 +195,43 @@ class M(BaseModel): m = M(key=StandardKeyBinding.Copy) assert m.key.primary == KeyMod.CtrlCmd | KeyCode.KeyC + + +@pytest.mark.parametrize( + "enc, os, expect", + [ + (KeyMod.CtrlCmd | KeyCode.KeyX, OperatingSystem.MACOS, "Cmd+X"), + (KeyMod.CtrlCmd | KeyCode.KeyX, OperatingSystem.WINDOWS, "Ctrl+X"), + (KeyMod.WinCtrl | KeyCode.KeyX, OperatingSystem.MACOS, "Control+X"), + (KeyMod.WinCtrl | KeyCode.KeyX, OperatingSystem.WINDOWS, "Win+X"), + (KeyMod.Ctrl | KeyCode.KeyX, OperatingSystem.MACOS, "Control+X"), + (KeyMod.Ctrl | KeyCode.KeyX, OperatingSystem.WINDOWS, "Ctrl+X"), + (KeyMod.Meta | KeyCode.KeyX, OperatingSystem.MACOS, "Cmd+X"), + (KeyMod.Meta | KeyCode.KeyX, OperatingSystem.WINDOWS, "Win+X"), + # careful, it can be a bit confusing to combine Ctrl | CtrlCmd + # it's not recommended + ( + KeyMod.Ctrl | KeyMod.CtrlCmd | KeyCode.KeyX, + OperatingSystem.MACOS, + "Control+Cmd+X", + ), + ( + KeyMod.Ctrl | KeyMod.CtrlCmd | KeyCode.KeyX, + OperatingSystem.WINDOWS, + "Ctrl+X", + ), + ( + KeyMod.Meta | KeyMod.CtrlCmd | KeyCode.KeyX, + OperatingSystem.MACOS, + "Cmd+X", + ), + ( + KeyMod.Meta | KeyMod.CtrlCmd | KeyCode.KeyX, + OperatingSystem.WINDOWS, + "Ctrl+Win+X", + ), + ], +) +def test_keymod_ctrl_meta(enc: int, os: OperatingSystem, expect: str) -> None: + rule = KeyBindingRule(primary=enc) + assert rule.to_keybinding(os=os).to_text(os=os, joinchar="+") == expect