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
+ '''
+
+
+ '''
+# ---
# name: test_layers
'''