From f1e911bc75e31bedf16185098895ae83d5e9f537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Althviz=20Mor=C3=A9?= <16781833+dalthviz@users.noreply.github.com> Date: Thu, 18 Jul 2024 17:55:09 -0700 Subject: [PATCH] feat: add a way to get a user-facing string representation of keybindings (#211) * Add a way to get a user facing text representation of keybindings (with optional key symbols and taking into account OS/platform differences) * style: [pre-commit.ci] auto fixes [...] * Typing * More typing * Add 'Alt' key usage over test * Move mappings and logic to '_key_codes.py', introduce 'joinchar' param and add more tests * Apply suggestions from code review Co-authored-by: Talley Lambert * Update method names and add/improve methods docstrings --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Talley Lambert --- src/app_model/types/_keys/_key_codes.py | 87 +++++++++++++++++++++++ src/app_model/types/_keys/_keybindings.py | 62 +++++++++++++++- tests/test_key_codes.py | 25 +++++++ tests/test_keybindings.py | 57 +++++++++++++++ 4 files changed, 230 insertions(+), 1 deletion(-) diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index 4c719d4..d0f6ecc 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. @@ -150,8 +154,35 @@ 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 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 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) + @classmethod def from_string(cls, string: str) -> 'KeyCode': """Return the `KeyCode` associated with the given string. @@ -429,6 +460,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 +604,44 @@ 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 + 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: "⌘", + }, + } + + # key names mappings per platform + 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", + }, + } + seen_scancodes: Set[ScanCode] = set() seen_keycodes: Set[KeyCode] = set() for i, km in enumerate(_MAPPINGS): @@ -602,6 +673,18 @@ 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 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 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.""" # sourcery skip @@ -617,6 +700,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 +710,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 215bebe..60f27fd 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -37,6 +37,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+" @@ -110,6 +111,44 @@ 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 _mods2keycodes(self) -> List[KeyCode]: + """Create KeyCode instances list of modifiers from this SimpleKeyBinding.""" + mods = [] + if self.ctrl: + mods.append(KeyCode.Ctrl) + if self.shift: + mods.append(KeyCode.Shift) + if self.alt: + mods.append(KeyCode.Alt) + if self.meta: + mods.append(KeyCode.Meta) + return mods + + def to_text( + 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 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. + """ + os = OperatingSystem.current() if os is None else os + keybinding_elements = [*self._mods2keycodes()] + if self.key: + keybinding_elements.append(self.key) + + return joinchar.join( + kbe.os_symbol(os=os) if use_symbols else kbe.os_name(os=os) + for kbe in keybinding_elements + ) + @classmethod def _parse_input(cls, v: Any) -> "SimpleKeyBinding": if isinstance(v, SimpleKeyBinding): @@ -152,6 +191,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: @@ -199,7 +239,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" @@ -210,6 +250,26 @@ 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, + 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 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. + """ + return " ".join( + part.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) + for part in self.parts + ) + def __int__(self) -> int: return int(self.to_int()) diff --git a/tests/test_key_codes.py b/tests/test_key_codes.py index 79b6e11..dcfa4ff 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: + 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, 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 b714d79..1223db6 100644 --- a/tests/test_keybindings.py +++ b/tests/test_keybindings.py @@ -12,11 +12,68 @@ 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", "joinchar", "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: bool, + os: OperatingSystem, + joinchar: str, + expected_use_symbols: str, + expected_non_use_symbols: str, +) -> None: + 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, joinchar=joinchar) == expected + + +@pytest.mark.parametrize("use_symbols", [True, False]) +@pytest.mark.parametrize( + ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), + [ + ( + 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( + use_symbols: bool, + os: OperatingSystem, + joinchar: str, + expected_use_symbols: str, + expected_non_use_symbols: str, +) -> None: + kb = KeyBinding.from_str("Ctrl+A Shift+[ Alt+/ Meta+9") + expected = expected_non_use_symbols + if use_symbols: + expected = expected_use_symbols + assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == 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: