Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sensible default key displays + allow users to override key displays at the App level #1213

Merged
merged 7 commits into from
Nov 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added Pilot.wait_for_animation
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
- 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

Expand Down
23 changes: 19 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@
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
Expand Down Expand Up @@ -102,7 +102,6 @@
ComposeResult = Iterable[Widget]
RenderResult = RenderableType


AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]"


Expand Down Expand Up @@ -668,6 +667,22 @@ def bind(
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
Expand Down Expand Up @@ -705,7 +720,7 @@ async def _press_keys(self, keys: Iterable[str]) -> None:
# 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
Expand Down
28 changes: 28 additions & 0 deletions src/textual/keys.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import unicodedata
from enum import Enum


Expand Down Expand Up @@ -219,7 +220,34 @@ class Keys(str, Enum):
"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
12 changes: 7 additions & 5 deletions src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rich.text import Text

from .. import events
from ..keys import _get_key_display
from ..reactive import Reactive, watch
from ..widget import Widget

Expand Down Expand Up @@ -99,11 +100,12 @@ def make_key_text(self) -> Text:

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),
Expand Down
157 changes: 157 additions & 0 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -6166,6 +6166,163 @@

'''
# ---
# name: test_key_display
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>

@font-face {
font-family: "Fira Code";
src: local("FiraCode-Regular"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
font-style: normal;
font-weight: 400;
}
@font-face {
font-family: "Fira Code";
src: local("FiraCode-Bold"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
font-style: bold;
font-weight: 700;
}

.terminal-1765381587-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}

.terminal-1765381587-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}

.terminal-1765381587-r1 { fill: #e1e1e1 }
.terminal-1765381587-r2 { fill: #c5c8c6 }
.terminal-1765381587-r3 { fill: #dde8f3;font-weight: bold }
.terminal-1765381587-r4 { fill: #ddedf9 }
</style>

<defs>
<clipPath id="terminal-1765381587-clip-terminal">
<rect x="0" y="0" width="975.0" height="584.5999999999999" />
</clipPath>
<clipPath id="terminal-1765381587-line-0">
<rect x="0" y="1.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-1">
<rect x="0" y="25.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-2">
<rect x="0" y="50.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-3">
<rect x="0" y="74.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-4">
<rect x="0" y="99.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-5">
<rect x="0" y="123.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-6">
<rect x="0" y="147.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-7">
<rect x="0" y="172.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-8">
<rect x="0" y="196.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-9">
<rect x="0" y="221.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-10">
<rect x="0" y="245.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-11">
<rect x="0" y="269.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-12">
<rect x="0" y="294.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-13">
<rect x="0" y="318.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-14">
<rect x="0" y="343.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-15">
<rect x="0" y="367.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-16">
<rect x="0" y="391.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-17">
<rect x="0" y="416.3" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-18">
<rect x="0" y="440.7" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-19">
<rect x="0" y="465.1" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-20">
<rect x="0" y="489.5" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-21">
<rect x="0" y="513.9" width="976" height="24.65"/>
</clipPath>
<clipPath id="terminal-1765381587-line-22">
<rect x="0" y="538.3" width="976" height="24.65"/>
</clipPath>
</defs>

<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1765381587-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">KeyDisplayApp</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
<circle cx="44" cy="0" r="7" fill="#28c840"/>
</g>

<g transform="translate(9, 41)" clip-path="url(#terminal-1765381587-clip-terminal)">
<rect fill="#1e1e1e" x="0" y="1.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="0" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="36.6" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="158.6" y="562.7" width="48.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="207.4" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="329.4" y="562.7" width="109.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="439.2" y="562.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0053aa" x="536.8" y="562.7" width="36.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="573.4" y="562.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="695.4" y="562.7" width="280.6" height="24.65" shape-rendering="crispEdges"/>
<g class="terminal-1765381587-matrix">
<text class="terminal-1765381587-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1765381587-line-0)">
</text><text class="terminal-1765381587-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-1)">
</text><text class="terminal-1765381587-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-2)">
</text><text class="terminal-1765381587-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-3)">
</text><text class="terminal-1765381587-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-4)">
</text><text class="terminal-1765381587-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1765381587-line-5)">
</text><text class="terminal-1765381587-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-6)">
</text><text class="terminal-1765381587-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-7)">
</text><text class="terminal-1765381587-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-8)">
</text><text class="terminal-1765381587-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-9)">
</text><text class="terminal-1765381587-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1765381587-line-10)">
</text><text class="terminal-1765381587-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-11)">
</text><text class="terminal-1765381587-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-12)">
</text><text class="terminal-1765381587-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-13)">
</text><text class="terminal-1765381587-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-14)">
</text><text class="terminal-1765381587-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1765381587-line-15)">
</text><text class="terminal-1765381587-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-16)">
</text><text class="terminal-1765381587-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-17)">
</text><text class="terminal-1765381587-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1765381587-line-18)">
</text><text class="terminal-1765381587-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1765381587-line-19)">
</text><text class="terminal-1765381587-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1765381587-line-20)">
</text><text class="terminal-1765381587-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1765381587-line-21)">
</text><text class="terminal-1765381587-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1765381587-line-22)">
</text><text class="terminal-1765381587-r3" x="0" y="581.2" textLength="36.6" clip-path="url(#terminal-1765381587-line-23)">&#160;?&#160;</text><text class="terminal-1765381587-r4" x="36.6" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Question&#160;</text><text class="terminal-1765381587-r3" x="158.6" y="581.2" textLength="48.8" clip-path="url(#terminal-1765381587-line-23)">&#160;^q&#160;</text><text class="terminal-1765381587-r4" x="207.4" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Quit&#160;app&#160;</text><text class="terminal-1765381587-r3" x="329.4" y="581.2" textLength="109.8" clip-path="url(#terminal-1765381587-line-23)">&#160;Escape!&#160;</text><text class="terminal-1765381587-r4" x="439.2" y="581.2" textLength="97.6" clip-path="url(#terminal-1765381587-line-23)">&#160;Escape&#160;</text><text class="terminal-1765381587-r3" x="536.8" y="581.2" textLength="36.6" clip-path="url(#terminal-1765381587-line-23)">&#160;A&#160;</text><text class="terminal-1765381587-r4" x="573.4" y="581.2" textLength="122" clip-path="url(#terminal-1765381587-line-23)">&#160;Letter&#160;A&#160;</text>
</g>
</g>
</svg>

'''
# ---
# name: test_layers
'''
<svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
Expand Down
36 changes: 36 additions & 0 deletions tests/snapshot_tests/snapshot_apps/key_display.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")