From 3d3036b2fb95c71008f016678baaeb42b3d07b60 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 11:14:44 -0400 Subject: [PATCH 1/5] feat: more flexible keybinding parser --- src/app_model/types/__init__.py | 2 + src/app_model/types/_keys/_keybindings.py | 52 +++++++---------------- tests/test_keybindings.py | 27 ++++++++++-- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/src/app_model/types/__init__.py b/src/app_model/types/__init__.py index 52e6609f..49da2589 100644 --- a/src/app_model/types/__init__.py +++ b/src/app_model/types/__init__.py @@ -4,6 +4,7 @@ from ._action import Action from ._command_rule import CommandRule, ToggleRule +from ._constants import OperatingSystem from ._icon import Icon from ._keybinding_rule import KeyBindingRule from ._keys import ( @@ -41,6 +42,7 @@ "KeyCode", "KeyCombo", "KeyMod", + "OperatingSystem", "MenuItem", "MenuItemBase", "MenuRule", diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index ae63843b..a42f8eec 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -11,13 +11,6 @@ from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema -_re_ctrl = re.compile(r"ctrl[\+|\-]") -_re_shift = re.compile(r"shift[\+|\-]") -_re_alt = re.compile(r"alt[\+|\-]") -_re_meta = re.compile(r"meta[\+|\-]") -_re_win = re.compile(r"win[\+|\-]") -_re_cmd = re.compile(r"cmd[\+|\-]") - class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" @@ -251,6 +244,12 @@ def validate(cls, v: Any) -> "KeyBinding": raise TypeError("invalid keybinding") # pragma: no cover +_re_ctrl = re.compile(r"(ctrl|control|ctl)[\+|\-]") +_re_shift = re.compile(r"(shift|⇧)[\+|\-]") +_re_alt = re.compile(r"(alt|opt|option|⌥)[\+|\-]") +_re_meta = re.compile(r"(meta|super|win|windows|⊞|cmd|command|⌘)[\+|\-]") + + def _parse_modifiers(input: str) -> Tuple[Dict[str, bool], str]: """Parse modifiers from a string (case insensitive). @@ -259,38 +258,17 @@ def _parse_modifiers(input: str) -> Tuple[Dict[str, bool], str]: """ remainder = input.lower() - ctrl = False - shift = False - alt = False - meta = False - + patterns = {"ctrl": _re_ctrl, "shift": _re_shift, "alt": _re_alt, "meta": _re_meta} + mods = dict.fromkeys(patterns, False) while True: saw_modifier = False - if _re_ctrl.match(remainder): - remainder = remainder[5:] - ctrl = True - saw_modifier = True - if _re_shift.match(remainder): - remainder = remainder[6:] - shift = True - saw_modifier = True - if _re_alt.match(remainder): - remainder = remainder[4:] - alt = True - saw_modifier = True - if _re_meta.match(remainder): - remainder = remainder[5:] - meta = True - saw_modifier = True - if _re_win.match(remainder): - remainder = remainder[4:] - meta = True - saw_modifier = True - if _re_cmd.match(remainder): - remainder = remainder[4:] - meta = True - saw_modifier = True + for key, ptrn in patterns.items(): + if m := ptrn.match(remainder): + remainder = remainder[m.span()[1] :] + mods[key] = True + saw_modifier = True + break if not saw_modifier: break - return {"ctrl": ctrl, "shift": shift, "alt": alt, "meta": meta}, remainder + return mods, remainder diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index b1f40934..ef84161d 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -1,3 +1,4 @@ +import itertools import sys from typing import ClassVar @@ -45,7 +46,7 @@ def test_simple_keybinding_single_mod(mod: str, key: str) -> None: assert int(as_full_kb) == int(kb) -def test_simple_keybinding_multi_mod(): +def test_simple_keybinding_multi_mod() -> None: # here we're also testing that cmd and win get cast to 'KeyMod.CtrlCmd' kb = SimpleKeyBinding.from_str("cmd+shift+A") @@ -60,6 +61,24 @@ def test_simple_keybinding_multi_mod(): assert kb.is_modifier_key() +controls = ["ctrl", "control", "ctl"] +shifts = ["shift", "⇧"] +alts = ["alt", "opt", "option", "⌥"] +metas = ["meta", "super", "cmd", "command", "⌘", "win", "windows", "⊞"] +delimiters = ["+", "-"] +key = ["A"] +combos = [ + delim.join(x) + for delim, *x in itertools.product(delimiters, controls, shifts, alts, metas, key) +] + + +@pytest.mark.parametrize("key", combos) +def test_keybinding_parser(key: str) -> None: + # Test all the different ways to write the modifiers + assert str(KeyBinding.from_str(key)) == "Ctrl+Shift+Alt+Meta+A" + + def test_chord_keybinding() -> None: kb = KeyBinding.from_str("Shift+A Cmd+9") assert len(kb) == 2 @@ -75,7 +94,7 @@ def test_chord_keybinding() -> None: assert KeyBinding.validate(kb) == kb -def test_in_dict(): +def test_in_dict() -> None: a = SimpleKeyBinding.from_str("Shift+A") b = KeyBinding.from_str("Shift+B") @@ -99,7 +118,7 @@ def test_in_dict(): kbs[new_a] -def test_in_model(): +def test_in_model() -> None: class M(BaseModel): key: KeyBinding @@ -113,7 +132,7 @@ class Config: assert m.model_dump_json().replace('": "', '":"') == '{"key":"Shift+A B"}' -def test_standard_keybindings(): +def test_standard_keybindings() -> None: class M(BaseModel): key: KeyBindingRule From b700a902412179f9142a4b10ce55d01a1fe7a7cf Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 11:16:12 -0400 Subject: [PATCH 2/5] add ctrl symbol --- src/app_model/types/_keys/_keybindings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index a42f8eec..76babd5e 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -244,7 +244,7 @@ def validate(cls, v: Any) -> "KeyBinding": raise TypeError("invalid keybinding") # pragma: no cover -_re_ctrl = re.compile(r"(ctrl|control|ctl)[\+|\-]") +_re_ctrl = re.compile(r"(ctrl|control|ctl|⌃)[\+|\-]") _re_shift = re.compile(r"(shift|⇧)[\+|\-]") _re_alt = re.compile(r"(alt|opt|option|⌥)[\+|\-]") _re_meta = re.compile(r"(meta|super|win|windows|⊞|cmd|command|⌘)[\+|\-]") From cfbbdc93dd82942bad082a5b70cff3085ab5b47c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 11:16:21 -0400 Subject: [PATCH 3/5] add test --- tests/test_keybindings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index ef84161d..f4dc5394 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -61,7 +61,7 @@ def test_simple_keybinding_multi_mod() -> None: assert kb.is_modifier_key() -controls = ["ctrl", "control", "ctl"] +controls = ["ctrl", "control", "ctl", "⌃"] shifts = ["shift", "⇧"] alts = ["alt", "opt", "option", "⌥"] metas = ["meta", "super", "cmd", "command", "⌘", "win", "windows", "⊞"] From c14b965f9cd8ee4d7ea394f57ea9317c464da2be Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 11:20:13 -0400 Subject: [PATCH 4/5] add one more for control --- src/app_model/types/_keys/_keybindings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 76babd5e..3b883354 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -244,7 +244,7 @@ def validate(cls, v: Any) -> "KeyBinding": raise TypeError("invalid keybinding") # pragma: no cover -_re_ctrl = re.compile(r"(ctrl|control|ctl|⌃)[\+|\-]") +_re_ctrl = re.compile(r"(ctrl|control|ctl|⌃|^)[\+|\-]") _re_shift = re.compile(r"(shift|⇧)[\+|\-]") _re_alt = re.compile(r"(alt|opt|option|⌥)[\+|\-]") _re_meta = re.compile(r"(meta|super|win|windows|⊞|cmd|command|⌘)[\+|\-]") From 48e14fec12bd8d3ebad3c8a61be2402a988a8ab5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Thu, 11 Jul 2024 11:21:03 -0400 Subject: [PATCH 5/5] one more for control --- src/app_model/types/_keys/_keybindings.py | 2 +- tests/test_keybindings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 3b883354..215bebe3 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -244,7 +244,7 @@ def validate(cls, v: Any) -> "KeyBinding": raise TypeError("invalid keybinding") # pragma: no cover -_re_ctrl = re.compile(r"(ctrl|control|ctl|⌃|^)[\+|\-]") +_re_ctrl = re.compile(r"(ctrl|control|ctl|⌃|\^)[\+|\-]") _re_shift = re.compile(r"(shift|⇧)[\+|\-]") _re_alt = re.compile(r"(alt|opt|option|⌥)[\+|\-]") _re_meta = re.compile(r"(meta|super|win|windows|⊞|cmd|command|⌘)[\+|\-]") diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index f4dc5394..b714d793 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -61,7 +61,7 @@ def test_simple_keybinding_multi_mod() -> None: assert kb.is_modifier_key() -controls = ["ctrl", "control", "ctl", "⌃"] +controls = ["ctrl", "control", "ctl", "⌃", "^"] shifts = ["shift", "⇧"] alts = ["alt", "opt", "option", "⌥"] metas = ["meta", "super", "cmd", "command", "⌘", "win", "windows", "⊞"]