Skip to content

Commit

Permalink
feat: add KeyCombo.Ctrl and KeybindingRule.to_keybinding
Browse files Browse the repository at this point in the history
  • Loading branch information
tlambert03 committed Dec 15, 2024
1 parent a0d95b1 commit 5ebf88c
Show file tree
Hide file tree
Showing 6 changed files with 113 additions and 56 deletions.
53 changes: 25 additions & 28 deletions src/app_model/registries/_keybindings_reg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +14,7 @@
from app_model.types import (
Action,
DisposeCallable,
KeyBinding,
KeyBindingRule,
KeyBindingSource,
)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/app_model/types/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

@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

@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
Expand Down
37 changes: 24 additions & 13 deletions src/app_model/types/_keybinding_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -54,14 +50,29 @@ 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: OperatingSystem | None = None
) -> "Optional[KeyBinding]":
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")

def for_os(self, os: OperatingSystem | None = None) -> Optional[KeyEncoding]:
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
Expand Down
6 changes: 5 additions & 1 deletion src/app_model/types/_keys/_key_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": ...
Expand Down
21 changes: 13 additions & 8 deletions src/app_model/types/_keys/_keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 5ebf88c

Please sign in to comment.