Skip to content

Commit 1f2781c

Browse files
authored
Merge branch 'main' into remove-freeze-fix
2 parents d90a1ea + 6ad9256 commit 1f2781c

File tree

10 files changed

+299
-13
lines changed

10 files changed

+299
-13
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Change Log
22

3+
34
All notable changes to this project will be documented in this file.
45

56
The format is based on [Keep a Changelog](http://keepachangelog.com/)
@@ -27,12 +28,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2728
- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121
2829
- Added a `Label` widget https://github.com/Textualize/textual/issues/1190
2930
- Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185
31+
- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213
32+
- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213
3033

3134
### Changed
3235

3336
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145
3437
- Widget.call_later has been renamed to Widget.call_after_refresh.
3538
- Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189
39+
- Added caching of some properties in Styles object
3640

3741
### Fixed
3842

examples/five_by_five.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ class FiveByFive(App[None]):
311311
CSS_PATH = "five_by_five.css"
312312
"""The name of the stylesheet for the app."""
313313

314-
SCREENS = {"help": Help()}
314+
SCREENS = {"help": Help}
315315
"""The pre-loaded screens for the application."""
316316

317317
BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")]

src/textual/app.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,14 @@
5757
from .features import FeatureFlag, parse_features
5858
from .file_monitor import FileMonitor
5959
from .geometry import Offset, Region, Size
60-
from .keys import REPLACED_KEYS
60+
from .keys import REPLACED_KEYS, _get_key_display
6161
from .messages import CallbackType
6262
from .reactive import Reactive
6363
from .renderables.blank import Blank
6464
from .screen import Screen
6565
from .widget import AwaitMount, MountError, Widget
6666

67+
6768
if TYPE_CHECKING:
6869
from .devtools.client import DevtoolsClient
6970
from .pilot import Pilot
@@ -103,7 +104,6 @@
103104
ComposeResult = Iterable[Widget]
104105
RenderResult = RenderableType
105106

106-
107107
AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]"
108108

109109

@@ -670,6 +670,22 @@ def bind(
670670
keys, action, description, show=show, key_display=key_display
671671
)
672672

673+
def get_key_display(self, key: str) -> str:
674+
"""For a given key, return how it should be displayed in an app
675+
(e.g. in the Footer widget).
676+
By key, we refer to the string used in the "key" argument for
677+
a Binding instance. By overriding this method, you can ensure that
678+
keys are displayed consistently throughout your app, without
679+
needing to add a key_display to every binding.
680+
681+
Args:
682+
key (str): The binding key string.
683+
684+
Returns:
685+
str: The display string for the input key.
686+
"""
687+
return _get_key_display(key)
688+
673689
async def _press_keys(self, keys: Iterable[str]) -> None:
674690
"""A task to send key events."""
675691
app = self
@@ -707,7 +723,7 @@ async def _press_keys(self, keys: Iterable[str]) -> None:
707723
# This conditional sleep can be removed after that issue is closed.
708724
if key == "tab":
709725
await asyncio.sleep(0.05)
710-
await asyncio.sleep(0.02)
726+
await asyncio.sleep(0.025)
711727
await app._animator.wait_for_idle()
712728

713729
@asynccontextmanager

src/textual/css/styles.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ def _align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset
557557
class Styles(StylesBase):
558558
node: DOMNode | None = None
559559
_rules: RulesMap = field(default_factory=dict)
560+
_updates: int = 0
560561

561562
important: set[str] = field(default_factory=set)
562563

@@ -577,6 +578,7 @@ def clear_rule(self, rule: str) -> bool:
577578
Returns:
578579
bool: ``True`` if a rule was cleared, or ``False`` if it was already not set.
579580
"""
581+
self._updates += 1
580582
return self._rules.pop(rule, None) is not None
581583

582584
def get_rules(self) -> RulesMap:
@@ -592,6 +594,7 @@ def set_rule(self, rule: str, value: object | None) -> bool:
592594
Returns:
593595
bool: ``True`` if the rule changed, otherwise ``False``.
594596
"""
597+
self._updates += 1
595598
if value is None:
596599
return self._rules.pop(rule, None) is not None
597600
current = self._rules.get(rule)
@@ -610,6 +613,7 @@ def refresh(self, *, layout: bool = False, children: bool = False) -> None:
610613

611614
def reset(self) -> None:
612615
"""Reset the rules to initial state."""
616+
self._updates += 1
613617
self._rules.clear()
614618

615619
def merge(self, other: Styles) -> None:
@@ -618,10 +622,11 @@ def merge(self, other: Styles) -> None:
618622
Args:
619623
other (Styles): A Styles object.
620624
"""
621-
625+
self._updates += 1
622626
self._rules.update(other._rules)
623627

624628
def merge_rules(self, rules: RulesMap) -> None:
629+
self._updates += 1
625630
self._rules.update(rules)
626631

627632
def extract_rules(
@@ -929,6 +934,18 @@ def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None:
929934
self._base_styles = base
930935
self._inline_styles = inline_styles
931936
self._animate: BoundAnimator | None = None
937+
self._updates: int = 0
938+
self._rich_style: tuple[int, Style] | None = None
939+
self._gutter: tuple[int, Spacing] | None = None
940+
941+
@property
942+
def _cache_key(self) -> int:
943+
"""A key key, that changes when any style is changed.
944+
945+
Returns:
946+
int: An opaque integer.
947+
"""
948+
return self._updates + self._base_styles._updates + self._inline_styles._updates
932949

933950
@property
934951
def base(self) -> Styles:
@@ -946,6 +963,21 @@ def rich_style(self) -> Style:
946963
assert self.node is not None
947964
return self.node.rich_style
948965

966+
@property
967+
def gutter(self) -> Spacing:
968+
"""Get space around widget.
969+
970+
Returns:
971+
Spacing: Space around widget content.
972+
"""
973+
if self._gutter is not None:
974+
cache_key, gutter = self._gutter
975+
if cache_key == self._updates:
976+
return gutter
977+
gutter = self.padding + self.border.spacing
978+
self._gutter = (self._cache_key, gutter)
979+
return gutter
980+
949981
def animate(
950982
self,
951983
attribute: str,
@@ -972,6 +1004,7 @@ def animate(
9721004
9731005
"""
9741006
if self._animate is None:
1007+
assert self.node is not None
9751008
self._animate = self.node.app.animator.bind(self)
9761009
assert self._animate is not None
9771010
self._animate(
@@ -1003,16 +1036,19 @@ def merge(self, other: Styles) -> None:
10031036

10041037
def merge_rules(self, rules: RulesMap) -> None:
10051038
self._inline_styles.merge_rules(rules)
1039+
self._updates += 1
10061040

10071041
def reset(self) -> None:
10081042
"""Reset the rules to initial state."""
10091043
self._inline_styles.reset()
1044+
self._updates += 1
10101045

10111046
def has_rule(self, rule: str) -> bool:
10121047
"""Check if a rule has been set."""
10131048
return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule)
10141049

10151050
def set_rule(self, rule: str, value: object | None) -> bool:
1051+
self._updates += 1
10161052
return self._inline_styles.set_rule(rule, value)
10171053

10181054
def get_rule(self, rule: str, default: object = None) -> object:
@@ -1022,6 +1058,7 @@ def get_rule(self, rule: str, default: object = None) -> object:
10221058

10231059
def clear_rule(self, rule_name: str) -> bool:
10241060
"""Clear a rule (from inline)."""
1061+
self._updates += 1
10251062
return self._inline_styles.clear_rule(rule_name)
10261063

10271064
def get_rules(self) -> RulesMap:

src/textual/keys.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import unicodedata
34
from enum import Enum
45

56

@@ -219,7 +220,34 @@ class Keys(str, Enum):
219220
"ctrl+j": ["newline"],
220221
}
221222

223+
KEY_DISPLAY_ALIASES = {
224+
"up": "↑",
225+
"down": "↓",
226+
"left": "←",
227+
"right": "→",
228+
"backspace": "⌫",
229+
"escape": "ESC",
230+
"enter": "⏎",
231+
}
232+
222233

223234
def _get_key_aliases(key: str) -> list[str]:
224235
"""Return all aliases for the given key, including the key itself"""
225236
return [key] + KEY_ALIASES.get(key, [])
237+
238+
239+
def _get_key_display(key: str) -> str:
240+
"""Given a key (i.e. the `key` string argument to Binding __init__),
241+
return the value that should be displayed in the app when referring
242+
to this key (e.g. in the Footer widget)."""
243+
display_alias = KEY_DISPLAY_ALIASES.get(key)
244+
if display_alias:
245+
return display_alias
246+
247+
original_key = REPLACED_KEYS.get(key, key)
248+
try:
249+
unicode_character = unicodedata.lookup(original_key.upper().replace("_", " "))
250+
except KeyError:
251+
return original_key.upper()
252+
253+
return unicode_character

src/textual/reactive.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class Reactive(Generic[ReactiveType]):
4343
layout (bool, optional): Perform a layout on change. Defaults to False.
4444
repaint (bool, optional): Perform a repaint on change. Defaults to True.
4545
init (bool, optional): Call watchers on initialize (post mount). Defaults to False.
46-
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
46+
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
4747
"""
4848

4949
def __init__(
@@ -76,7 +76,7 @@ def init(
7676
default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default.
7777
layout (bool, optional): Perform a layout on change. Defaults to False.
7878
repaint (bool, optional): Perform a repaint on change. Defaults to True.
79-
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
79+
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
8080
8181
Returns:
8282
Reactive: A Reactive instance which calls watchers or initialize.
@@ -292,7 +292,7 @@ class reactive(Reactive[ReactiveType]):
292292
layout (bool, optional): Perform a layout on change. Defaults to False.
293293
repaint (bool, optional): Perform a repaint on change. Defaults to True.
294294
init (bool, optional): Call watchers on initialize (post mount). Defaults to True.
295-
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
295+
always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
296296
"""
297297

298298
def __init__(

src/textual/widgets/_footer.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from rich.text import Text
88

99
from .. import events
10+
from ..keys import _get_key_display
1011
from ..reactive import Reactive, watch
1112
from ..widget import Widget
1213

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

100101
for action, bindings in action_to_bindings.items():
101102
binding = bindings[0]
102-
key_display = (
103-
binding.key.upper()
104-
if binding.key_display is None
105-
else binding.key_display
106-
)
103+
if binding.key_display is None:
104+
key_display = self.app.get_key_display(binding.key)
105+
if key_display is None:
106+
key_display = binding.key.upper()
107+
else:
108+
key_display = binding.key_display
107109
hovered = self.highlight_key == binding.key
108110
key_text = Text.assemble(
109111
(f" {key_display} ", highlight_key_style if hovered else key_style),

0 commit comments

Comments
 (0)