Skip to content

Commit 3c67916

Browse files
committed
Binding groups
1 parent 8d78427 commit 3c67916

File tree

6 files changed

+87
-28
lines changed

6 files changed

+87
-28
lines changed

src/textual/app.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,8 @@ def __init__(
813813
self._resize_event: events.Resize | None = None
814814
"""A pending resize event, sent on idle."""
815815

816+
self._size: Size | None = None
817+
816818
self._css_update_count: int = 0
817819
"""Incremented when CSS is invalidated."""
818820

@@ -1532,6 +1534,8 @@ def size(self) -> Size:
15321534
Returns:
15331535
Size of the terminal.
15341536
"""
1537+
if self._size is not None:
1538+
return self._size
15351539
if self._driver is not None and self._driver._size is not None:
15361540
width, height = self._driver._size
15371541
else:
@@ -4114,6 +4118,7 @@ async def _on_key(self, event: events.Key) -> None:
41144118

41154119
async def _on_resize(self, event: events.Resize) -> None:
41164120
event.stop()
4121+
self._size = event.size
41174122
self._resize_event = event
41184123

41194124
async def _on_app_focus(self, event: events.AppFocus) -> None:

src/textual/binding.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class Binding:
6666
key_display: str | None = None
6767
"""How the key should be shown in footer.
6868
69-
If None, the display of the key will use the result of `App.get_key_display`.
69+
If `None`, the display of the key will use the result of `App.get_key_display`.
7070
7171
If overridden in a keymap then this value is ignored.
7272
"""
@@ -84,6 +84,14 @@ class Binding:
8484
system: bool = False
8585
"""Make this binding a system binding, which removes it from the key panel."""
8686

87+
@dataclass(frozen=True)
88+
class Group:
89+
description: str = ""
90+
"""Description of the group."""
91+
92+
group: Group | None = None
93+
"""Optional binding group (used to group related bindings in the footer)."""
94+
8795
def parse_key(self) -> tuple[list[str], str]:
8896
"""Parse a key into a list of modifiers, and the actual key.
8997
@@ -151,6 +159,7 @@ def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
151159
tooltip=binding.tooltip,
152160
id=binding.id,
153161
system=binding.system,
162+
group=binding.group,
154163
)
155164

156165

src/textual/css/styles.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,7 +1463,6 @@ def has_any_rules(self, *rule_names: str) -> bool:
14631463
return any(inline_has_rule(name) or base_has_rule(name) for name in rule_names)
14641464

14651465
def set_rule(self, rule_name: str, value: object | None) -> bool:
1466-
self._updates += 1
14671466
return self._inline_styles.set_rule(rule_name, value)
14681467

14691468
def get_rule(self, rule_name: str, default: object = None) -> object:
@@ -1473,7 +1472,6 @@ def get_rule(self, rule_name: str, default: object = None) -> object:
14731472

14741473
def clear_rule(self, rule_name: str) -> bool:
14751474
"""Clear a rule (from inline)."""
1476-
self._updates += 1
14771475
return self._inline_styles.clear_rule(rule_name)
14781476

14791477
def get_rules(self) -> RulesMap:

src/textual/reactive.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None:
222222
else default_or_callable
223223
)
224224
setattr(obj, internal_name, default)
225+
if (toggle_class := self._toggle_class) is not None:
226+
obj.set_class(bool(default), *toggle_class.split())
225227
if self._init:
226228
self._check_watchers(obj, name, default)
227229

src/textual/widget.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4391,7 +4391,8 @@ def _check_refresh(self) -> None:
43914391
screen.post_message(messages.UpdateScroll())
43924392
if self._repaint_required:
43934393
self._repaint_required = False
4394-
screen.post_message(messages.Update(self))
4394+
if self.display:
4395+
screen.post_message(messages.Update(self))
43954396
if self._layout_required:
43964397
self._layout_required = False
43974398
screen.post_message(messages.Layout())

src/textual/widgets/_footer.py

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from collections import defaultdict
4+
from itertools import groupby
45
from typing import TYPE_CHECKING
56

67
import rich.repr
@@ -12,6 +13,7 @@
1213
from textual.containers import ScrollableContainer
1314
from textual.reactive import reactive
1415
from textual.widget import Widget
16+
from textual.widgets import Label
1517

1618
if TYPE_CHECKING:
1719
from textual.screen import Screen
@@ -64,6 +66,7 @@ class FooterKey(Widget):
6466
"""
6567

6668
compact = reactive(True)
69+
"""Display compact style."""
6770

6871
def __init__(
6972
self,
@@ -95,18 +98,22 @@ def render(self) -> Text:
9598
"footer-key--description"
9699
).padding
97100
description = self.description
98-
label_text = Text.assemble(
99-
(
100-
" " * key_padding.left + key_display + " " * key_padding.right,
101-
key_style,
102-
),
103-
(
104-
" " * description_padding.left
105-
+ description
106-
+ " " * description_padding.right,
107-
description_style,
108-
),
109-
)
101+
if description:
102+
label_text = Text.assemble(
103+
(
104+
" " * key_padding.left + key_display + " " * key_padding.right,
105+
key_style,
106+
),
107+
(
108+
" " * description_padding.left
109+
+ description
110+
+ " " * description_padding.right,
111+
description_style,
112+
),
113+
)
114+
else:
115+
label_text = Text.assemble((key_display, key_style))
116+
110117
label_text.stylize_before(self.rich_style)
111118
return label_text
112119

@@ -120,13 +127,17 @@ def _watch_compact(self, compact: bool) -> None:
120127
self.set_class(compact, "-compact")
121128

122129

130+
class FooterLabel(Label):
131+
pass
132+
133+
123134
@rich.repr.auto
124135
class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
125136
ALLOW_SELECT = False
126137
DEFAULT_CSS = """
127138
Footer {
128-
layout: grid;
129-
grid-columns: auto;
139+
layout: horizontal;
140+
# grid-columns: auto;
130141
color: $footer-foreground;
131142
background: $footer-background;
132143
dock: bottom;
@@ -140,6 +151,11 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
140151
padding-right: 1;
141152
border-left: vkey $foreground 20%;
142153
}
154+
HorizontalGroup.binding-group {
155+
width: auto;
156+
height: 1;
157+
layout: horizontal;
158+
}
143159
144160
&:ansi {
145161
background: ansi_default;
@@ -164,6 +180,15 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
164180
border-left: vkey ansi_black;
165181
}
166182
}
183+
FooterKey.-grouped {
184+
margin: 0 1;
185+
}
186+
FooterLabel {
187+
margin: 0 1;
188+
background: red;
189+
color: $footer-description-foreground;
190+
background: $footer-description-background;
191+
}
167192
}
168193
"""
169194

@@ -173,6 +198,8 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False):
173198
"""True if the bindings are ready to be displayed."""
174199
show_command_palette = reactive(True)
175200
"""Show the key to invoke the command palette."""
201+
combine_groups = reactive(True)
202+
"""Combine bindings in the same group?"""
176203

177204
def __init__(
178205
self,
@@ -217,16 +244,33 @@ def compose(self) -> ComposeResult:
217244
action_to_bindings[binding.action].append((binding, enabled, tooltip))
218245

219246
self.styles.grid_size_columns = len(action_to_bindings)
220-
for multi_bindings in action_to_bindings.values():
221-
binding, enabled, tooltip = multi_bindings[0]
222-
yield FooterKey(
223-
binding.key,
224-
self.app.get_key_display(binding),
225-
binding.description,
226-
binding.action,
227-
disabled=not enabled,
228-
tooltip=tooltip,
229-
).data_bind(Footer.compact)
247+
248+
for group, multi_bindings_iterable in groupby(
249+
action_to_bindings.values(),
250+
lambda multi_bindings: multi_bindings[0][0].group,
251+
):
252+
if group is not None:
253+
for multi_bindings in multi_bindings_iterable:
254+
binding, enabled, tooltip = multi_bindings[0]
255+
yield FooterKey(
256+
binding.key,
257+
self.app.get_key_display(binding),
258+
"",
259+
binding.action,
260+
classes="-grouped",
261+
).data_bind(Footer.compact)
262+
yield FooterLabel(group.description)
263+
else:
264+
for multi_bindings in multi_bindings_iterable:
265+
binding, enabled, tooltip = multi_bindings[0]
266+
yield FooterKey(
267+
binding.key,
268+
self.app.get_key_display(binding),
269+
binding.description,
270+
binding.action,
271+
disabled=not enabled,
272+
tooltip=tooltip,
273+
).data_bind(Footer.compact)
230274
if self.show_command_palette and self.app.ENABLE_COMMAND_PALETTE:
231275
try:
232276
_node, binding, enabled, tooltip = active_bindings[

0 commit comments

Comments
 (0)