From 36664ef7ae88303f02c282c5f3cc9b080098969b Mon Sep 17 00:00:00 2001 From: darrenburns Date: Fri, 18 Nov 2022 14:05:45 +0000 Subject: [PATCH] Sensible default key displays + allow users to override key displays at the `App` level (#1213) * Get rid of string split key display * Include screen level bindings when no widget is focused * Add default key display mappings * Allow user to customise key display at app level * Better docstring * Update CHANGELOG.md --- CHANGELOG.md | 2 + src/textual/app.py | 23 ++- src/textual/keys.py | 28 ++++ src/textual/widgets/_footer.py | 12 +- .../__snapshots__/test_snapshots.ambr | 157 ++++++++++++++++++ .../snapshot_apps/key_display.py | 36 ++++ tests/snapshot_tests/test_snapshots.py | 6 + 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/key_display.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cb6dc7e..1323d53bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 - Added a `Label` widget https://github.com/Textualize/textual/issues/1190 - Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 +- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213 +- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index e74a90f8a..ba95fb6ef 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -56,12 +56,12 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size -from .keys import REPLACED_KEYS +from .keys import REPLACED_KEYS, _get_key_display from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen -from .widget import AwaitMount, Widget, MountError +from .widget import AwaitMount, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient @@ -102,7 +102,6 @@ DEFAULT_COLORS = { ComposeResult = Iterable[Widget] RenderResult = RenderableType - AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" @@ -668,6 +667,22 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) + def get_key_display(self, key: str) -> str: + """For a given key, return how it should be displayed in an app + (e.g. in the Footer widget). + By key, we refer to the string used in the "key" argument for + a Binding instance. By overriding this method, you can ensure that + keys are displayed consistently throughout your app, without + needing to add a key_display to every binding. + + Args: + key (str): The binding key string. + + Returns: + str: The display string for the input key. + """ + return _get_key_display(key) + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" app = self @@ -705,7 +720,7 @@ class App(Generic[ReturnType], DOMNode): # This conditional sleep can be removed after that issue is closed. if key == "tab": await asyncio.sleep(0.05) - await asyncio.sleep(0.02) + await asyncio.sleep(0.025) await app._animator.wait_for_idle() @asynccontextmanager diff --git a/src/textual/keys.py b/src/textual/keys.py index e6d386c68..aac14c138 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -1,5 +1,6 @@ from __future__ import annotations +import unicodedata from enum import Enum @@ -219,7 +220,34 @@ KEY_ALIASES = { "ctrl+j": ["newline"], } +KEY_DISPLAY_ALIASES = { + "up": "↑", + "down": "↓", + "left": "←", + "right": "→", + "backspace": "⌫", + "escape": "ESC", + "enter": "⏎", +} + def _get_key_aliases(key: str) -> list[str]: """Return all aliases for the given key, including the key itself""" return [key] + KEY_ALIASES.get(key, []) + + +def _get_key_display(key: str) -> str: + """Given a key (i.e. the `key` string argument to Binding __init__), + return the value that should be displayed in the app when referring + to this key (e.g. in the Footer widget).""" + display_alias = KEY_DISPLAY_ALIASES.get(key) + if display_alias: + return display_alias + + original_key = REPLACED_KEYS.get(key, key) + try: + unicode_character = unicodedata.lookup(original_key.upper().replace("_", " ")) + except KeyError: + return original_key.upper() + + return unicode_character diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index fe39d8b5a..fd8353ab6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -7,6 +7,7 @@ from rich.console import RenderableType from rich.text import Text from .. import events +from ..keys import _get_key_display from ..reactive import Reactive, watch from ..widget import Widget @@ -99,11 +100,12 @@ class Footer(Widget): for action, bindings in action_to_bindings.items(): binding = bindings[0] - key_display = ( - binding.key.upper() - if binding.key_display is None - else binding.key_display - ) + if binding.key_display is None: + key_display = self.app.get_key_display(binding.key) + if key_display is None: + key_display = binding.key.upper() + else: + key_display = binding.key_display hovered = self.highlight_key == binding.key key_text = Text.assemble( (f" {key_display} ", highlight_key_style if hovered else key_style), diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index cb6252b33..354a78d0f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -6166,6 +6166,163 @@ ''' # --- +# name: test_key_display + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyDisplayApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  + + + + + ''' +# --- # name: test_layers ''' diff --git a/tests/snapshot_tests/snapshot_apps/key_display.py b/tests/snapshot_tests/snapshot_apps/key_display.py new file mode 100644 index 000000000..9762bcff6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/key_display.py @@ -0,0 +1,36 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer + + +class KeyDisplayApp(App): + """Tests how keys are displayed in the Footer, and ensures + that overriding the key_displays works as expected. + Exercises both the built-in Textual key display replacements, + and user supplied replacements. + Will break when we update the Footer - but we should add a similar + test (or updated snapshot) for the updated Footer.""" + BINDINGS = [ + Binding("question_mark", "question", "Question"), + Binding("ctrl+q", "quit", "Quit app"), + Binding("escape", "escape", "Escape"), + Binding("a", "a", "Letter A"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + def get_key_display(self, key: str) -> str: + key_display_replacements = { + "escape": "Escape!", + "ctrl+q": "^q", + } + display = key_display_replacements.get(key) + if display: + return display + return super().get_key_display(key) + + +app = KeyDisplayApp() +if __name__ == '__main__': + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 95e83c278..f5116cc78 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -118,3 +118,9 @@ def test_css_property(file_name, snap_compare): def test_multiple_css(snap_compare): # Interaction between multiple CSS files and app-level/classvar CSS assert snap_compare("snapshot_apps/multiple_css/multiple_css.py") + + +# --- Other --- + +def test_key_display(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")