From 87a88fca85d84ca78ecde8c861bd82719d0fbb0c Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:13:58 -0500 Subject: [PATCH 1/8] Add a way to get a user facing text representation of keybindings (with optional key symbols and taking into account OS/platform differences) --- src/app_model/types/_keys/_keybindings.py | 93 ++++++++++++++++++++++- tests/test_keybindings.py | 39 ++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index ae63843b..13ca6a23 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -19,6 +19,58 @@ _re_cmd = re.compile(r"cmd[\+|\-]") +def get_keys_user_facing_representation( + os: Optional[OperatingSystem] = None, +) -> tuple[str, dict, dict]: + """Get user-facing strings representation of keys following current or given OS.""" + os = OperatingSystem.current() if os is None else os + joinchar = "+" + key_symbols = { + "Ctrl": "Ctrl", + "Shift": "⇧", + "Alt": "Alt", + "Meta": "⊞", + "Left": "←", + "Right": "→", + "Up": "↑", + "Down": "↓", + "Backspace": "⌫", + "Delete": "⌦", + "Tab": "↹", + "Escape": "Esc", + "Return": "⏎", + "Enter": "↵", + "Space": "␣", + } + key_names = { + "Ctrl": "Ctrl", + "Shift": "Shift", + "Alt": "Alt", + "Meta": "Win", + "Left": "Left", + "Right": "Right", + "Up": "Up", + "Down": "Down", + "Backspace": "Backspace", + "Delete": "Supr", + "Tab": "Tab", + "Escape": "Esc", + "Return": "Return", + "Enter": "Enter", + "Space": "Space", + } + + if os == OperatingSystem.MACOS: + joinchar = "" + key_symbols.update({"Ctrl": "⌃", "Alt": "⌥", "Meta": "⌘"}) + key_names.update({"Ctrl": "Control", "Alt": "Option", "Meta": "Cmd"}) + elif os == OperatingSystem.LINUX: + key_symbols.update({"Meta": "Super"}) + key_names.update({"Meta": "Super"}) + + return joinchar, key_symbols, key_names + + class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" @@ -117,6 +169,33 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl return mods | (self.key or 0) + def _kb2mods(self) -> list[str]: + """Extract list of modifiers from this SimpleKeyBinding.""" + mods = [] + if self.ctrl: + mods.append("Ctrl") + if self.shift: + mods.append("Shift") + if self.alt: + mods.append("Alt") + if self.meta: + mods.append("Meta") + return mods + + def to_text( + self, os: Optional[OperatingSystem] = None, use_symbols: bool = False + ) -> str: + """Get a user-facing string representation of this SimpleKeyBinding. + + Optionally, the string representation can be constructed with symbols + like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + """ + joinchar, key_symbols, key_names = get_keys_user_facing_representation(os=os) + return joinchar.join( + key_symbols.get(x, x) if use_symbols else key_names.get(x, x) + for x in ([*self._kb2mods(), str(self.key)]) + ) + @classmethod def _parse_input(cls, v: Any) -> "SimpleKeyBinding": if isinstance(v, SimpleKeyBinding): @@ -206,7 +285,7 @@ def from_int( return cls(parts=[SimpleKeyBinding.from_int(first_part, os)]) def to_int(self, os: Optional[OperatingSystem] = None) -> int: - """Convert this SimpleKeyBinding to an integer representation.""" + """Convert this KeyBinding to an integer representation.""" if len(self.parts) > 2: # pragma: no cover raise NotImplementedError( "Cannot represent chords with more than 2 parts as int" @@ -217,6 +296,18 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: return KeyChord(*parts) return parts[0] + def to_text( + self, os: Optional[OperatingSystem] = None, use_symbols: bool = False + ) -> str: + """Get a text representation of this KeyBinding. + + Optionally, the string representation can be constructed with symbols + like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + """ + return " ".join( + part.to_text(os=os, use_symbols=use_symbols) for part in self.parts + ) + def __int__(self) -> int: return int(self.to_int()) diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index b1f40934..3fa3ddd3 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -11,11 +11,50 @@ KeyMod, SimpleKeyBinding, ) +from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCombo, StandardKeyBinding MAC = sys.platform == "darwin" +@pytest.mark.parametrize("use_symbols", [True, False]) +@pytest.mark.parametrize( + ("os", "expected_use_symbols", "expected_non_use_symbols"), + [ + (OperatingSystem.WINDOWS, "⊞+A", "Win+A"), + (OperatingSystem.LINUX, "Super+A", "Super+A"), + (OperatingSystem.MACOS, "⌘A", "CmdA"), + ] +) +def test_simple_keybinding_to_text( + use_symbols, os, expected_use_symbols, expected_non_use_symbols +): + kb = SimpleKeyBinding.from_str("Meta+A") + expected = expected_non_use_symbols + if use_symbols: + expected = expected_use_symbols + assert kb.to_text(os=os, use_symbols=use_symbols) == expected + + +@pytest.mark.parametrize("use_symbols", [True, False]) +@pytest.mark.parametrize( + ("os", "expected_use_symbols", "expected_non_use_symbols"), + [ + (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ ⊞+9", "Ctrl+A Shift+[ Win+9"), + (OperatingSystem.LINUX, "Ctrl+A ⇧+[ Super+9", "Ctrl+A Shift+[ Super+9"), + (OperatingSystem.MACOS, "⌃A ⇧[ ⌘9", "ControlA Shift[ Cmd9"), + ] +) +def test_keybinding_to_text( + use_symbols, os, expected_use_symbols, expected_non_use_symbols +): + kb = KeyBinding.from_str("Ctrl+A Shift+[ Meta+9") + expected = expected_non_use_symbols + if use_symbols: + expected = expected_use_symbols + assert kb.to_text(os=os, use_symbols=use_symbols) == expected + + @pytest.mark.parametrize("key", list("ADgf`]/,")) @pytest.mark.parametrize("mod", ["ctrl", "shift", "alt", "meta", None]) def test_simple_keybinding_single_mod(mod: str, key: str) -> None: From 25a443ed4998aeab0749ca98d24a5ad8886dc9a9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 18:53:36 +0000 Subject: [PATCH 2/8] style: [pre-commit.ci] auto fixes [...] --- tests/test_keybindings.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index 3fa3ddd3..b6afa23a 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -21,10 +21,10 @@ @pytest.mark.parametrize( ("os", "expected_use_symbols", "expected_non_use_symbols"), [ - (OperatingSystem.WINDOWS, "⊞+A", "Win+A"), - (OperatingSystem.LINUX, "Super+A", "Super+A"), - (OperatingSystem.MACOS, "⌘A", "CmdA"), - ] + (OperatingSystem.WINDOWS, "⊞+A", "Win+A"), + (OperatingSystem.LINUX, "Super+A", "Super+A"), + (OperatingSystem.MACOS, "⌘A", "CmdA"), + ], ) def test_simple_keybinding_to_text( use_symbols, os, expected_use_symbols, expected_non_use_symbols @@ -40,10 +40,10 @@ def test_simple_keybinding_to_text( @pytest.mark.parametrize( ("os", "expected_use_symbols", "expected_non_use_symbols"), [ - (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ ⊞+9", "Ctrl+A Shift+[ Win+9"), - (OperatingSystem.LINUX, "Ctrl+A ⇧+[ Super+9", "Ctrl+A Shift+[ Super+9"), - (OperatingSystem.MACOS, "⌃A ⇧[ ⌘9", "ControlA Shift[ Cmd9"), - ] + (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ ⊞+9", "Ctrl+A Shift+[ Win+9"), + (OperatingSystem.LINUX, "Ctrl+A ⇧+[ Super+9", "Ctrl+A Shift+[ Super+9"), + (OperatingSystem.MACOS, "⌃A ⇧[ ⌘9", "ControlA Shift[ Cmd9"), + ], ) def test_keybinding_to_text( use_symbols, os, expected_use_symbols, expected_non_use_symbols From 37dbf0a9c8b32a1541f938a4c18d3b9d82eec8ae Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Wed, 10 Jul 2024 13:59:44 -0500 Subject: [PATCH 3/8] Typing --- 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 13ca6a23..5b8b8722 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -21,7 +21,7 @@ def get_keys_user_facing_representation( os: Optional[OperatingSystem] = None, -) -> tuple[str, dict, dict]: +) -> Tuple[str, Dict[str, str], Dict[str, str]]: """Get user-facing strings representation of keys following current or given OS.""" os = OperatingSystem.current() if os is None else os joinchar = "+" From af50347d27bee80645d1318f0722b0863cb3a548 Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:06:38 -0500 Subject: [PATCH 4/8] More typing --- src/app_model/types/_keys/_keybindings.py | 2 +- tests/test_keybindings.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 5b8b8722..aa5ae21d 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -169,7 +169,7 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl return mods | (self.key or 0) - def _kb2mods(self) -> list[str]: + def _kb2mods(self) -> List[str]: """Extract list of modifiers from this SimpleKeyBinding.""" mods = [] if self.ctrl: diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index b6afa23a..71c00496 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -27,8 +27,11 @@ ], ) def test_simple_keybinding_to_text( - use_symbols, os, expected_use_symbols, expected_non_use_symbols -): + use_symbols: bool, + os: OperatingSystem, + expected_use_symbols: str, + expected_non_use_symbols: str, +) -> None: kb = SimpleKeyBinding.from_str("Meta+A") expected = expected_non_use_symbols if use_symbols: @@ -46,8 +49,11 @@ def test_simple_keybinding_to_text( ], ) def test_keybinding_to_text( - use_symbols, os, expected_use_symbols, expected_non_use_symbols -): + use_symbols: bool, + os: OperatingSystem, + expected_use_symbols: str, + expected_non_use_symbols: str, +) -> None: kb = KeyBinding.from_str("Ctrl+A Shift+[ Meta+9") expected = expected_non_use_symbols if use_symbols: From 845fb940d165c0e57753b1d52fd1ab2778585919 Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Wed, 10 Jul 2024 14:18:25 -0500 Subject: [PATCH 5/8] Add 'Alt' key usage over test --- tests/test_keybindings.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index 71c00496..930d1ebb 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -43,9 +43,13 @@ def test_simple_keybinding_to_text( @pytest.mark.parametrize( ("os", "expected_use_symbols", "expected_non_use_symbols"), [ - (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ ⊞+9", "Ctrl+A Shift+[ Win+9"), - (OperatingSystem.LINUX, "Ctrl+A ⇧+[ Super+9", "Ctrl+A Shift+[ Super+9"), - (OperatingSystem.MACOS, "⌃A ⇧[ ⌘9", "ControlA Shift[ Cmd9"), + (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ Alt+/ ⊞+9", "Ctrl+A Shift+[ Alt+/ Win+9"), + ( + OperatingSystem.LINUX, + "Ctrl+A ⇧+[ Alt+/ Super+9", + "Ctrl+A Shift+[ Alt+/ Super+9", + ), + (OperatingSystem.MACOS, "⌃A ⇧[ ⌥/ ⌘9", "ControlA Shift[ Option/ Cmd9"), ], ) def test_keybinding_to_text( @@ -54,7 +58,7 @@ def test_keybinding_to_text( expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: - kb = KeyBinding.from_str("Ctrl+A Shift+[ Meta+9") + kb = KeyBinding.from_str("Ctrl+A Shift+[ Alt+/ Meta+9") expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols From a9cd05c0939fd9542a72918700a2fbc8c1773713 Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Fri, 12 Jul 2024 10:57:41 -0500 Subject: [PATCH 6/8] Move mappings and logic to '_key_codes.py', introduce 'joinchar' param and add more tests --- src/app_model/types/_keys/_key_codes.py | 76 +++++++++++++++++++ src/app_model/types/_keys/_keybindings.py | 91 +++++++---------------- tests/test_key_codes.py | 25 +++++++ tests/test_keybindings.py | 30 +++++--- 4 files changed, 147 insertions(+), 75 deletions(-) diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index 4c719d48..be2991b3 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -6,6 +6,7 @@ Dict, Generator, NamedTuple, + Optional, Set, Tuple, Type, @@ -13,6 +14,8 @@ overload, ) +from app_model.types._constants import OperatingSystem + if TYPE_CHECKING: from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema @@ -25,6 +28,7 @@ # flake8: noqa # fmt: off + class KeyCode(IntEnum): """Virtual Key Codes, the integer value does not hold any inherent meaning. @@ -152,6 +156,14 @@ class KeyCode(IntEnum): def __str__(self) -> str: return keycode_to_string(self) + def to_os_symbol(self, os: Optional[OperatingSystem] = None) -> str: + os = OperatingSystem.current() if os is None else os + return keycode_to_os_symbol(self, os) + + def to_os_name(self, os: Optional[OperatingSystem] = None) -> str: + os = OperatingSystem.current() if os is None else os + return keycode_to_os_name(self, os) + @classmethod def from_string(cls, string: str) -> 'KeyCode': """Return the `KeyCode` associated with the given string. @@ -429,6 +441,8 @@ def from_string(cls, string: str) -> 'ScanCode': def _build_maps() -> Tuple[ Callable[[KeyCode], str], Callable[[str], KeyCode], + Callable[[KeyCode, OperatingSystem], str], + Callable[[KeyCode, OperatingSystem], str], Callable[[ScanCode], str], Callable[[str], ScanCode], ]: @@ -571,6 +585,48 @@ class _KM(NamedTuple): 'cmd': KeyCode.Meta, } + # key symbols mappings per platform + WIN_KEY_SYMBOLS: dict[str, str] = { + "Ctrl": "Ctrl", + "Shift": "⇧", + "Alt": "Alt", + "Meta": "⊞", + "Left": "←", + "Right": "→", + "Up": "↑", + "Down": "↓", + "Backspace": "⌫", + "Delete": "⌦", + "Tab": "↹", + "Escape": "Esc", + "Return": "⏎", + "Enter": "↵", + "Space": "␣", + } + MACOS_KEY_SYMBOLS: dict[str, str] = {**WIN_KEY_SYMBOLS, "Ctrl": "⌃", "Alt": "⌥", "Meta": "⌘"} + LINUX_KEY_SYMBOLS: dict[str, str] = {**WIN_KEY_SYMBOLS, "Meta": "Super"} + + # key names mappings per platform + WIN_KEY_NAMES: dict[str, str] = { + "Ctrl": "Ctrl", + "Shift": "Shift", + "Alt": "Alt", + "Meta": "Win", + "Left": "Left", + "Right": "Right", + "Up": "Up", + "Down": "Down", + "Backspace": "Backspace", + "Delete": "Supr", + "Tab": "Tab", + "Escape": "Esc", + "Return": "Return", + "Enter": "Enter", + "Space": "Space", + } + MACOS_KEY_NAMES: dict[str, str] = {**WIN_KEY_NAMES, "Ctrl": "Control", "Alt": "Option", "Meta": "Cmd"} + LINUX_KEY_NAMES: dict[str, str] = {**WIN_KEY_NAMES, "Meta": "Super"} + seen_scancodes: Set[ScanCode] = set() seen_keycodes: Set[KeyCode] = set() for i, km in enumerate(_MAPPINGS): @@ -602,6 +658,22 @@ def _keycode_from_string(keystr: str) -> KeyCode: # sourcery skip return KEYCODE_FROM_LOWERCASE_STRING.get(str(keystr).lower(), KeyCode.UNKNOWN) + def _keycode_to_os_symbol(keycode: KeyCode, os: OperatingSystem) -> str: + """Return key symbol for an OS for a given KeyCode.""" + if os == OperatingSystem.MACOS: + return MACOS_KEY_SYMBOLS.get(str(keycode), str(keycode)) + elif os == OperatingSystem.LINUX: + return LINUX_KEY_SYMBOLS.get(str(keycode), str(keycode)) + return WIN_KEY_SYMBOLS.get(str(keycode), str(keycode)) + + def _keycode_to_os_name(keycode: KeyCode, os: OperatingSystem) -> str: + """Return key name for an OS for a given KeyCode.""" + if os == OperatingSystem.MACOS: + return MACOS_KEY_NAMES.get(str(keycode), str(keycode)) + elif os == OperatingSystem.LINUX: + return LINUX_KEY_NAMES.get(str(keycode), str(keycode)) + return WIN_KEY_NAMES.get(str(keycode), str(keycode)) + def _scancode_to_string(scancode: ScanCode) -> str: """Return the string representation of a ScanCode.""" # sourcery skip @@ -617,6 +689,8 @@ def _scancode_from_string(scanstr: str) -> ScanCode: return ( _keycode_to_string, _keycode_from_string, + _keycode_to_os_symbol, + _keycode_to_os_name, _scancode_to_string, _scancode_from_string, ) @@ -625,6 +699,8 @@ def _scancode_from_string(scanstr: str) -> ScanCode: ( keycode_to_string, keycode_from_string, + keycode_to_os_symbol, + keycode_to_os_name, scancode_to_string, scancode_from_string, ) = _build_maps() diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index aa5ae21d..3eeb45b4 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -19,58 +19,6 @@ _re_cmd = re.compile(r"cmd[\+|\-]") -def get_keys_user_facing_representation( - os: Optional[OperatingSystem] = None, -) -> Tuple[str, Dict[str, str], Dict[str, str]]: - """Get user-facing strings representation of keys following current or given OS.""" - os = OperatingSystem.current() if os is None else os - joinchar = "+" - key_symbols = { - "Ctrl": "Ctrl", - "Shift": "⇧", - "Alt": "Alt", - "Meta": "⊞", - "Left": "←", - "Right": "→", - "Up": "↑", - "Down": "↓", - "Backspace": "⌫", - "Delete": "⌦", - "Tab": "↹", - "Escape": "Esc", - "Return": "⏎", - "Enter": "↵", - "Space": "␣", - } - key_names = { - "Ctrl": "Ctrl", - "Shift": "Shift", - "Alt": "Alt", - "Meta": "Win", - "Left": "Left", - "Right": "Right", - "Up": "Up", - "Down": "Down", - "Backspace": "Backspace", - "Delete": "Supr", - "Tab": "Tab", - "Escape": "Esc", - "Return": "Return", - "Enter": "Enter", - "Space": "Space", - } - - if os == OperatingSystem.MACOS: - joinchar = "" - key_symbols.update({"Ctrl": "⌃", "Alt": "⌥", "Meta": "⌘"}) - key_names.update({"Ctrl": "Control", "Alt": "Option", "Meta": "Cmd"}) - elif os == OperatingSystem.LINUX: - key_symbols.update({"Meta": "Super"}) - key_names.update({"Meta": "Super"}) - - return joinchar, key_symbols, key_names - - class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" @@ -169,31 +117,40 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl return mods | (self.key or 0) - def _kb2mods(self) -> List[str]: - """Extract list of modifiers from this SimpleKeyBinding.""" + def _mods2keycodes(self) -> List[KeyCode]: + """Create KeyCode instances list of modifiers from this SimpleKeyBinding.""" mods = [] if self.ctrl: - mods.append("Ctrl") + mods.append(KeyCode.from_string("Ctrl")) if self.shift: - mods.append("Shift") + mods.append(KeyCode.from_string("Shift")) if self.alt: - mods.append("Alt") + mods.append(KeyCode.from_string("Alt")) if self.meta: - mods.append("Meta") + mods.append(KeyCode.from_string("Meta")) return mods def to_text( - self, os: Optional[OperatingSystem] = None, use_symbols: bool = False + self, + os: Optional[OperatingSystem] = None, + use_symbols: bool = False, + joinchar: str = "+", ) -> str: """Get a user-facing string representation of this SimpleKeyBinding. Optionally, the string representation can be constructed with symbols like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + + Also, a join character can be defined. By default `+` is used. """ - joinchar, key_symbols, key_names = get_keys_user_facing_representation(os=os) + os = OperatingSystem.current() if os is None else os + keybinding_elements = [*self._mods2keycodes()] + if self.key: + keybinding_elements.append(self.key) + return joinchar.join( - key_symbols.get(x, x) if use_symbols else key_names.get(x, x) - for x in ([*self._kb2mods(), str(self.key)]) + kbe.to_os_symbol(os=os) if use_symbols else kbe.to_os_name(os=os) + for kbe in keybinding_elements ) @classmethod @@ -297,15 +254,21 @@ def to_int(self, os: Optional[OperatingSystem] = None) -> int: return parts[0] def to_text( - self, os: Optional[OperatingSystem] = None, use_symbols: bool = False + self, + os: Optional[OperatingSystem] = None, + use_symbols: bool = False, + joinchar: str = "+", ) -> str: """Get a text representation of this KeyBinding. Optionally, the string representation can be constructed with symbols like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + + Also, a join character can be defined. By default `+` is used. """ return " ".join( - part.to_text(os=os, use_symbols=use_symbols) for part in self.parts + part.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) + for part in self.parts ) def __int__(self) -> int: diff --git a/tests/test_key_codes.py b/tests/test_key_codes.py index 79b6e11a..250c8773 100644 --- a/tests/test_key_codes.py +++ b/tests/test_key_codes.py @@ -1,6 +1,10 @@ +from typing import Callable + import pytest +from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCode, KeyMod, ScanCode, SimpleKeyBinding +from app_model.types._keys._key_codes import keycode_to_os_name, keycode_to_os_symbol def test_key_codes(): @@ -17,6 +21,27 @@ def test_key_codes(): KeyCode.validate({"a"}) +@pytest.mark.parametrize("symbol_or_name", ["symbol", "name"]) +@pytest.mark.parametrize( + ("os", "key_symbols_func", "key_names_func"), + [ + (OperatingSystem.WINDOWS, keycode_to_os_symbol, keycode_to_os_name), + (OperatingSystem.MACOS, keycode_to_os_symbol, keycode_to_os_name), + (OperatingSystem.LINUX, keycode_to_os_symbol, keycode_to_os_name), + ], +) +def test_key_codes_to_os( + symbol_or_name: str, + os: OperatingSystem, + key_symbols_func: Callable[[KeyCode, OperatingSystem], str], + key_names_func: Callable[[KeyCode, OperatingSystem], str], +) -> None: + to_os_method = f"to_os_{symbol_or_name}" + key_map_func = key_symbols_func if symbol_or_name == "symbol" else key_names_func + for key in KeyCode: + assert getattr(key, to_os_method)(os) == key_map_func(key, os) + + def test_scan_codes(): for scan in ScanCode: assert scan == ScanCode.from_string(str(scan)), scan diff --git a/tests/test_keybindings.py b/tests/test_keybindings.py index 930d1ebb..62155d0e 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -19,16 +19,17 @@ @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( - ("os", "expected_use_symbols", "expected_non_use_symbols"), + ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ - (OperatingSystem.WINDOWS, "⊞+A", "Win+A"), - (OperatingSystem.LINUX, "Super+A", "Super+A"), - (OperatingSystem.MACOS, "⌘A", "CmdA"), + (OperatingSystem.WINDOWS, "+", "⊞+A", "Win+A"), + (OperatingSystem.LINUX, "-", "Super-A", "Super-A"), + (OperatingSystem.MACOS, "", "⌘A", "CmdA"), ], ) def test_simple_keybinding_to_text( use_symbols: bool, os: OperatingSystem, + joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: @@ -36,25 +37,32 @@ def test_simple_keybinding_to_text( expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols - assert kb.to_text(os=os, use_symbols=use_symbols) == expected + assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( - ("os", "expected_use_symbols", "expected_non_use_symbols"), + ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ - (OperatingSystem.WINDOWS, "Ctrl+A ⇧+[ Alt+/ ⊞+9", "Ctrl+A Shift+[ Alt+/ Win+9"), + ( + OperatingSystem.WINDOWS, + "+", + "Ctrl+A ⇧+[ Alt+/ ⊞+9", + "Ctrl+A Shift+[ Alt+/ Win+9", + ), ( OperatingSystem.LINUX, - "Ctrl+A ⇧+[ Alt+/ Super+9", - "Ctrl+A Shift+[ Alt+/ Super+9", + "-", + "Ctrl-A ⇧-[ Alt-/ Super-9", + "Ctrl-A Shift-[ Alt-/ Super-9", ), - (OperatingSystem.MACOS, "⌃A ⇧[ ⌥/ ⌘9", "ControlA Shift[ Option/ Cmd9"), + (OperatingSystem.MACOS, "", "⌃A ⇧[ ⌥/ ⌘9", "ControlA Shift[ Option/ Cmd9"), ], ) def test_keybinding_to_text( use_symbols: bool, os: OperatingSystem, + joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: @@ -62,7 +70,7 @@ def test_keybinding_to_text( expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols - assert kb.to_text(os=os, use_symbols=use_symbols) == expected + assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("key", list("ADgf`]/,")) From 5160031dd7b29f8e138480f24d94803e5e7136b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Althviz=20Mor=C3=A9?= <16781833+dalthviz@users.noreply.github.com> Date: Mon, 15 Jul 2024 13:25:24 -0500 Subject: [PATCH 7/8] Apply suggestions from code review Co-authored-by: Talley Lambert --- src/app_model/types/_keys/_key_codes.py | 84 ++++++++++------------- src/app_model/types/_keys/_keybindings.py | 8 +-- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index be2991b3..5daceab2 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -585,47 +585,43 @@ class _KM(NamedTuple): 'cmd': KeyCode.Meta, } + # key symbols on all platforms + KEY_SYMBOLS: dict[KeyCode, str] = { + KeyCode.Shift: "⇧", + KeyCode.LeftArrow: "←", + KeyCode.RightArrow: "→", + KeyCode.UpArrow: "↑", + KeyCode.DownArrow: "↓", + KeyCode.Backspace: "⌫", + KeyCode.Delete: "⌦", + KeyCode.Tab: "⇥", + KeyCode.Escape: "⎋", + KeyCode.Enter: "↵", + KeyCode.Space: "␣", + KeyCode.CapsLock: "⇪", + } # key symbols mappings per platform - WIN_KEY_SYMBOLS: dict[str, str] = { - "Ctrl": "Ctrl", - "Shift": "⇧", - "Alt": "Alt", - "Meta": "⊞", - "Left": "←", - "Right": "→", - "Up": "↑", - "Down": "↓", - "Backspace": "⌫", - "Delete": "⌦", - "Tab": "↹", - "Escape": "Esc", - "Return": "⏎", - "Enter": "↵", - "Space": "␣", + OS_KEY_SYMBOLS: dict[OperatingSystem, dict[KeyCode, str]] = { + OperatingSystem.WINDOWS: {**KEY_SYMBOLS, KeyCode.Meta: "⊞"}, + OperatingSystem.LINUX: {**KEY_SYMBOLS, KeyCode.Meta: "Super"}, + OperatingSystem.MACOS: { + **KEY_SYMBOLS, + KeyCode.Ctrl: "⌃", + KeyCode.Alt: "⌥", + KeyCode.Meta: "⌘", + }, } - MACOS_KEY_SYMBOLS: dict[str, str] = {**WIN_KEY_SYMBOLS, "Ctrl": "⌃", "Alt": "⌥", "Meta": "⌘"} - LINUX_KEY_SYMBOLS: dict[str, str] = {**WIN_KEY_SYMBOLS, "Meta": "Super"} # key names mappings per platform - WIN_KEY_NAMES: dict[str, str] = { - "Ctrl": "Ctrl", - "Shift": "Shift", - "Alt": "Alt", - "Meta": "Win", - "Left": "Left", - "Right": "Right", - "Up": "Up", - "Down": "Down", - "Backspace": "Backspace", - "Delete": "Supr", - "Tab": "Tab", - "Escape": "Esc", - "Return": "Return", - "Enter": "Enter", - "Space": "Space", + OS_KEY_NAMES: dict[OperatingSystem, dict[KeyCode, str]] = { + OperatingSystem.WINDOWS: {KeyCode.Meta: "Win"}, + OperatingSystem.LINUX: {KeyCode.Meta: "Super"}, + OperatingSystem.MACOS: { + KeyCode.Ctrl: "Control", + KeyCode.Alt: "Option", + KeyCode.Meta: "Cmd", + }, } - MACOS_KEY_NAMES: dict[str, str] = {**WIN_KEY_NAMES, "Ctrl": "Control", "Alt": "Option", "Meta": "Cmd"} - LINUX_KEY_NAMES: dict[str, str] = {**WIN_KEY_NAMES, "Meta": "Super"} seen_scancodes: Set[ScanCode] = set() seen_keycodes: Set[KeyCode] = set() @@ -660,19 +656,15 @@ def _keycode_from_string(keystr: str) -> KeyCode: def _keycode_to_os_symbol(keycode: KeyCode, os: OperatingSystem) -> str: """Return key symbol for an OS for a given KeyCode.""" - if os == OperatingSystem.MACOS: - return MACOS_KEY_SYMBOLS.get(str(keycode), str(keycode)) - elif os == OperatingSystem.LINUX: - return LINUX_KEY_SYMBOLS.get(str(keycode), str(keycode)) - return WIN_KEY_SYMBOLS.get(str(keycode), str(keycode)) + if keycode in (symbols := OS_KEY_SYMBOLS.get(os, {})): + return symbols[keycode] + return str(keycode) def _keycode_to_os_name(keycode: KeyCode, os: OperatingSystem) -> str: """Return key name for an OS for a given KeyCode.""" - if os == OperatingSystem.MACOS: - return MACOS_KEY_NAMES.get(str(keycode), str(keycode)) - elif os == OperatingSystem.LINUX: - return LINUX_KEY_NAMES.get(str(keycode), str(keycode)) - return WIN_KEY_NAMES.get(str(keycode), str(keycode)) + if keycode in (names := OS_KEY_NAMES.get(os, {})): + return names[keycode] + return str(keycode) def _scancode_to_string(scancode: ScanCode) -> str: """Return the string representation of a ScanCode.""" diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 3a3ea43a..4b8c3ebe 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -114,13 +114,13 @@ def _mods2keycodes(self) -> List[KeyCode]: """Create KeyCode instances list of modifiers from this SimpleKeyBinding.""" mods = [] if self.ctrl: - mods.append(KeyCode.from_string("Ctrl")) + mods.append(KeyCode.Ctrl) if self.shift: - mods.append(KeyCode.from_string("Shift")) + mods.append(KeyCode.Shift) if self.alt: - mods.append(KeyCode.from_string("Alt")) + mods.append(KeyCode.Alt) if self.meta: - mods.append(KeyCode.from_string("Meta")) + mods.append(KeyCode.Meta) return mods def to_text( From eb96af5aa80541dace77e1b0cbd23807ca378d8c Mon Sep 17 00:00:00 2001 From: dalthviz <16781833+dalthviz@users.noreply.github.com> Date: Tue, 16 Jul 2024 14:15:02 -0500 Subject: [PATCH 8/8] Update method names and add/improve methods docstrings --- src/app_model/types/_keys/_key_codes.py | 23 +++++++++++++++++++++-- src/app_model/types/_keys/_keybindings.py | 12 +++++++++--- tests/test_key_codes.py | 4 ++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index be2991b3..bf248f0a 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -154,13 +154,32 @@ class KeyCode(IntEnum): PauseBreak = auto() def __str__(self) -> str: + """Get a normalized string representation (constant to all OSes) of this `KeyCode`.""" return keycode_to_string(self) - def to_os_symbol(self, os: Optional[OperatingSystem] = None) -> str: + def os_symbol(self, os: Optional[OperatingSystem] = None) -> str: + """Get a string representation of this `KeyCode` using a symbol/OS specific symbol. + + Some examples: + * `KeyCode.Enter` is represented by `↵` + * `KeyCode.Meta` is represented by `⊞` on Windows, `Super` on Linux and `⌘` on MacOS + + If no OS is given, the current detected one is used. + """ os = OperatingSystem.current() if os is None else os return keycode_to_os_symbol(self, os) - def to_os_name(self, os: Optional[OperatingSystem] = None) -> str: + def os_name(self, os: Optional[OperatingSystem] = None) -> str: + """Get a string representation of this `KeyCode` using the OS specific naming for the key. + + This differs from `__str__` since with it a normalized representation (constant to all OSes) is given. + Sometimes these representations coincide but not always! Some examples: + * `KeyCode.Enter` is represented by `Enter` (`__str__` represents it as `Enter`) + * `KeyCode.Meta` is represented by `Win` on Windows, `Super` on Linux and `Cmd` on MacOS + (`__str__` represents it as `Meta`) + + If no OS is given, the current detected one is used. + """ os = OperatingSystem.current() if os is None else os return keycode_to_os_name(self, os) diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index 3eeb45b4..93ab0ee1 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -44,6 +44,7 @@ def is_modifier_key(self) -> bool: ) def __str__(self) -> str: + """Get a normalized string representation (constant to all OSes) of this SimpleKeyBinding.""" out = "" if self.ctrl: out += "Ctrl+" @@ -139,7 +140,9 @@ def to_text( """Get a user-facing string representation of this SimpleKeyBinding. Optionally, the string representation can be constructed with symbols - like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + like ↵ for Enter or OS specific ones like ⌘ for Meta on MacOS. If no symbols + should be used, the string representation will use the OS specific names + for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ @@ -149,7 +152,7 @@ def to_text( keybinding_elements.append(self.key) return joinchar.join( - kbe.to_os_symbol(os=os) if use_symbols else kbe.to_os_name(os=os) + kbe.os_symbol(os=os) if use_symbols else kbe.os_name(os=os) for kbe in keybinding_elements ) @@ -195,6 +198,7 @@ def __init__(self, *, parts: List[SimpleKeyBinding]): self.parts = parts def __str__(self) -> str: + """Get a normalized string representation (constant to all OSes) of this KeyBinding.""" return " ".join(str(part) for part in self.parts) def __repr__(self) -> str: @@ -262,7 +266,9 @@ def to_text( """Get a text representation of this KeyBinding. Optionally, the string representation can be constructed with symbols - like ↵ for Enter or OS specific ones like ⌘ for Cmd on MacOS. + like ↵ for Enter or OS specific ones like ⌘ for Meta on MacOS. If no symbols + should be used, the string representation will use the OS specific names + for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ diff --git a/tests/test_key_codes.py b/tests/test_key_codes.py index 250c8773..dcfa4ffd 100644 --- a/tests/test_key_codes.py +++ b/tests/test_key_codes.py @@ -36,10 +36,10 @@ def test_key_codes_to_os( key_symbols_func: Callable[[KeyCode, OperatingSystem], str], key_names_func: Callable[[KeyCode, OperatingSystem], str], ) -> None: - to_os_method = f"to_os_{symbol_or_name}" + os_method = f"os_{symbol_or_name}" key_map_func = key_symbols_func if symbol_or_name == "symbol" else key_names_func for key in KeyCode: - assert getattr(key, to_os_method)(os) == key_map_func(key, os) + assert getattr(key, os_method)(os) == key_map_func(key, os) def test_scan_codes():