Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add KeyCombo.Ctrl and KeybindingRule.to_keybinding #230

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
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 # 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
Expand Down
39 changes: 26 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,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
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
Loading