Skip to content

Commit

Permalink
feat: add a way to get a user-facing string representation of keybind…
Browse files Browse the repository at this point in the history
…ings (#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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
3 people authored Jul 19, 2024
1 parent 0e801a9 commit f1e911b
Show file tree
Hide file tree
Showing 4 changed files with 230 additions and 1 deletion.
87 changes: 87 additions & 0 deletions src/app_model/types/_keys/_key_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
Dict,
Generator,
NamedTuple,
Optional,
Set,
Tuple,
Type,
Union,
overload,
)

from app_model.types._constants import OperatingSystem

if TYPE_CHECKING:
from pydantic.annotated_handlers import GetCoreSchemaHandler
from pydantic_core import core_schema
Expand All @@ -25,6 +28,7 @@
# flake8: noqa
# fmt: off


class KeyCode(IntEnum):
"""Virtual Key Codes, the integer value does not hold any inherent meaning.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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],
]:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand All @@ -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()
Expand Down
62 changes: 61 additions & 1 deletion src/app_model/types/_keys/_keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+"
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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())

Expand Down
25 changes: 25 additions & 0 deletions tests/test_key_codes.py
Original file line number Diff line number Diff line change
@@ -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():
Expand All @@ -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
Expand Down
57 changes: 57 additions & 0 deletions tests/test_keybindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit f1e911b

Please sign in to comment.