Skip to content

Commit

Permalink
feat: initial exploration for keybinding source addition and inverse …
Browse files Browse the repository at this point in the history
…map for keybinding registry (#226)

* feat: initial exploration for keybinding source addition and inverse map for keybinding registry

* Testing

* Testing

* Some code clean up

* Apply suggestions from code review

Co-authored-by: Talley Lambert <[email protected]>

* Change KeyBindingSource.SYSTEM to keyBindingSource.APP

Co-authored-by: Draga Doncila Pop <[email protected]>

---------

Co-authored-by: Talley Lambert <[email protected]>
Co-authored-by: Draga Doncila Pop <[email protected]>
  • Loading branch information
3 people authored Dec 9, 2024
1 parent 56d0d4f commit 8a26cc3
Show file tree
Hide file tree
Showing 5 changed files with 263 additions and 11 deletions.
86 changes: 78 additions & 8 deletions src/app_model/registries/_keybindings_reg.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
from __future__ import annotations

from bisect import insort_left
from collections import defaultdict
from typing import TYPE_CHECKING, Callable, NamedTuple

from psygnal import Signal

from app_model.types import KeyBinding

if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterable, Iterator, Mapping
from typing import TypeVar

from app_model import expressions
from app_model.types import Action, DisposeCallable, KeyBindingRule
from app_model.types import (
Action,
DisposeCallable,
KeyBindingRule,
KeyBindingSource,
)

CommandDecorator = Callable[[Callable], Callable]
CommandCallable = TypeVar("CommandCallable", bound=Callable)
Expand All @@ -23,8 +30,24 @@ class _RegisteredKeyBinding(NamedTuple):
keybinding: KeyBinding # the keycode to bind to
command_id: str # the command to run
weight: int # the weight of the binding, for prioritization
source: KeyBindingSource # who defined the binding, for prioritization
when: expressions.Expr | None = None # condition to enable keybinding

def __gt__(self, other: object) -> bool:
if not isinstance(other, _RegisteredKeyBinding):
return NotImplemented
return (self.source, self.weight) > (other.source, other.weight)

def __lt__(self, other: object) -> bool:
if not isinstance(other, _RegisteredKeyBinding):
return NotImplemented
return (self.source, self.weight) < (other.source, other.weight)

def __eq__(self, other: object) -> bool:
if not isinstance(other, _RegisteredKeyBinding):
return NotImplemented
return (self.source, self.weight) == (other.source, other.weight)


class KeyBindingsRegistry:
"""Registry for keybindings.
Expand All @@ -38,11 +61,16 @@ class KeyBindingsRegistry:
"""

registered = Signal()
unregistered = Signal()

def __init__(self) -> None:
self._keybindings: list[_RegisteredKeyBinding] = []
self._keymap = defaultdict[int, list[_RegisteredKeyBinding]](list)
self._filter_keybinding: Callable[[KeyBinding], str] | None = None

@property
def _keybindings(self) -> Iterable[_RegisteredKeyBinding]:
return (entry for entries in self._keymap.values() for entry in entries)

@property
def filter_keybinding(self) -> Callable[[KeyBinding], str] | None:
"""Return the `filter_keybinding`."""
Expand Down Expand Up @@ -123,6 +151,7 @@ def register_keybinding_rule(
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)
Expand All @@ -133,26 +162,67 @@ def register_keybinding_rule(
command_id=id,
weight=rule.weight,
when=rule.when,
source=rule.source,
)
self._keybindings.append(entry)

# inverse map registry
entries = self._keymap[keybinding.to_int()]
insort_left(entries, entry)

self.registered.emit()

def _dispose() -> None:
self._keybindings.remove(entry)
# 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

def __iter__(self) -> Iterator[_RegisteredKeyBinding]:
yield from self._keybindings

def __len__(self) -> int:
return sum(len(entries) for entries in self._keymap.values())

def __repr__(self) -> str:
name = self.__class__.__name__
return f"<{name} at {hex(id(self))} ({len(self._keybindings)} bindings)>"
return f"<{name} at {hex(id(self))} ({len(self)} bindings)>"

def get_keybinding(self, key: str) -> _RegisteredKeyBinding | None:
def get_keybinding(self, command_id: str) -> _RegisteredKeyBinding | None:
"""Return the first keybinding that matches the given command ID."""
# TODO: improve me.
return next(
(entry for entry in self._keybindings if entry.command_id == key), None
(entry for entry in self._keybindings if entry.command_id == command_id),
None,
)

def get_context_prioritized_keybinding(
self, key: int, context: Mapping[str, object]
) -> _RegisteredKeyBinding | None:
"""
Return the first keybinding that matches the given key sequence.
The keybinding should be enabled given the context to be returned.
Parameters
----------
key : int
The key sequence that represent the keybinding.
context : Mapping[str, object]
Variable context to parse the `when` expression to determine if the
keybinding is enabled or not.
Returns
-------
_RegisteredKeyBinding | None
The keybinding found or None otherwise.
"""
if key in self._keymap:
for entry in reversed(self._keymap[key]):
if entry.when is None or entry.when.eval(context):
return entry
return None
3 changes: 2 additions & 1 deletion src/app_model/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from ._action import Action
from ._command_rule import CommandRule, ToggleRule
from ._constants import OperatingSystem
from ._constants import KeyBindingSource, OperatingSystem
from ._icon import Icon
from ._keybinding_rule import KeyBindingRule
from ._keys import (
Expand Down Expand Up @@ -42,6 +42,7 @@
"KeyCode",
"KeyCombo",
"KeyMod",
"KeyBindingSource",
"OperatingSystem",
"MenuItem",
"MenuItemBase",
Expand Down
10 changes: 9 additions & 1 deletion src/app_model/types/_constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import os
import sys
from enum import Enum
from enum import Enum, IntEnum


class KeyBindingSource(IntEnum):
"""Keybinding source enum."""

APP = 0
PLUGIN = 1
USER = 2


class OperatingSystem(Enum):
Expand Down
6 changes: 5 additions & 1 deletion src/app_model/types/_keybinding_rule.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from app_model import expressions

from ._base import _BaseModel
from ._constants import OperatingSystem
from ._constants import KeyBindingSource, OperatingSystem
from ._keys import StandardKeyBinding

KeyEncoding = Union[int, str]
Expand Down Expand Up @@ -49,6 +49,10 @@ class KeyBindingRule(_BaseModel):
description="Internal weight used to sort keybindings. "
"This is not part of the plugin schema",
)
source: KeyBindingSource = Field(
KeyBindingSource.APP,
description="Who registered the keybinding. Used to sort keybindings.",
)

def _bind_to_current_platform(self) -> Optional[KeyEncoding]:
if _WIN and self.win:
Expand Down
169 changes: 169 additions & 0 deletions tests/test_registries.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import pytest

from app_model.registries import KeyBindingsRegistry, MenusRegistry
from app_model.registries._keybindings_reg import _RegisteredKeyBinding
from app_model.types import (
Action,
KeyBinding,
KeyBindingRule,
KeyBindingSource,
KeyCode,
KeyMod,
MenuItem,
Expand Down Expand Up @@ -93,3 +95,170 @@ def test_register_action_keybindings_filter(kb, msg) -> None:
reg.register_action_keybindings(action)
else:
reg.register_action_keybindings(action)


@pytest.mark.parametrize(
"kb1, kb2, kb3",
[
(
[
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"when": "active",
"weight": 10,
},
],
[
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
},
],
[
{"primary": KeyMod.CtrlCmd | KeyCode.KeyA, "weight": 5},
],
),
],
)
def test_register_action_keybindings_priorization(kb1, kb2, kb3) -> None:
"""Check `get_context_prioritized_keybinding`."""
reg = KeyBindingsRegistry()

action1 = Action(
id="cmd_id1",
title="title1",
callback=lambda: None,
keybindings=kb1,
)
reg.register_action_keybindings(action1)

action2 = Action(
id="cmd_id2",
title="title2",
callback=lambda: None,
keybindings=kb2,
)
reg.register_action_keybindings(action2)

action3 = Action(
id="cmd_id3",
title="title3",
callback=lambda: None,
keybindings=kb3,
)
reg.register_action_keybindings(action3)

keybinding = reg.get_context_prioritized_keybinding(
kb1[0]["primary"], {"active": False}
)
assert keybinding.command_id == "cmd_id3"

keybinding = reg.get_context_prioritized_keybinding(
kb1[0]["primary"], {"active": True}
)
assert keybinding.command_id == "cmd_id1"

keybinding = reg.get_context_prioritized_keybinding(
KeyMod.Shift | kb1[0]["primary"], {"active": True}
)
assert keybinding is None


@pytest.mark.parametrize(
"kb1, kb2, gt, lt, eq",
[
(
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id1",
"weight": 0,
"when": None,
"source": KeyBindingSource.USER,
},
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id2",
"weight": 0,
"when": None,
"source": KeyBindingSource.APP,
},
True,
False,
False,
),
(
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id1",
"weight": 0,
"when": None,
"source": KeyBindingSource.USER,
},
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id2",
"weight": 10,
"when": None,
"source": KeyBindingSource.APP,
},
True,
False,
False,
),
(
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id1",
"weight": 0,
"when": None,
"source": KeyBindingSource.USER,
},
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id2",
"weight": 10,
"when": None,
"source": KeyBindingSource.USER,
},
False,
True,
False,
),
(
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id1",
"weight": 10,
"when": None,
"source": KeyBindingSource.USER,
},
{
"primary": KeyMod.CtrlCmd | KeyCode.KeyA,
"command_id": "command_id2",
"weight": 10,
"when": None,
"source": KeyBindingSource.USER,
},
False,
False,
True,
),
],
)
def test_registered_keybinding_comparison(kb1, kb2, gt, lt, eq):
rkb1 = _RegisteredKeyBinding(
keybinding=kb1["primary"],
command_id=kb1["command_id"],
weight=kb1["weight"],
when=kb1["when"],
source=kb1["source"],
)
rkb2 = _RegisteredKeyBinding(
keybinding=kb2["primary"],
command_id=kb2["command_id"],
weight=kb2["weight"],
when=kb2["when"],
source=kb2["source"],
)
assert (rkb1 > rkb2) == gt
assert (rkb1 < rkb2) == lt
assert (rkb1 == rkb2) == eq

0 comments on commit 8a26cc3

Please sign in to comment.