Skip to content

Commit 8992c54

Browse files
authored
Merge pull request #6070 from Textualize/binding-groups
Binding groups
2 parents f6f3bea + 1b912fa commit 8992c54

File tree

17 files changed

+325
-198
lines changed

17 files changed

+325
-198
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2222
- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048
2323
- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header https://github.com/Textualize/textual/pull/6051
2424
- Added `Widget.get_line_filters` and `App.get_line_filters` https://github.com/Textualize/textual/pull/6057
25+
- Added `Binding.Group` https://github.com/Textualize/textual/pull/6070
26+
- Added `DOMNode.displayed_children` https://github.com/Textualize/textual/pull/6070
27+
- Added `TextArea.UserInsert` message https://github.com/Textualize/textual/pull/6070
28+
- Added `TextArea.hide_suggestion_on_blur` boolean https://github.com/Textualize/textual/pull/6070
2529

2630
### Changed
2731

src/textual/_compositor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,8 @@ def render_full_update(self, simplify: bool = False) -> LayoutUpdate:
11321132
crop = screen_region
11331133
chops = self._render_chops(crop, lambda y: True)
11341134
if simplify:
1135+
# Simplify is done when exporting to SVG
1136+
# It doesn't make things faster
11351137
render_strips = [
11361138
Strip.join(chop.values()).simplify().discard_meta() for chop in chops
11371139
]

src/textual/_node_list.py

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import sys
44
import weakref
55
from operator import attrgetter
6-
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Sequence, overload
6+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload
77

88
import rich.repr
99

@@ -14,6 +14,10 @@
1414
from textual.widget import Widget
1515

1616

17+
_display_getter = attrgetter("display")
18+
_visible_getter = attrgetter("visible")
19+
20+
1721
class DuplicateIds(Exception):
1822
"""Raised when attempting to add a widget with an id that already exists."""
1923

@@ -41,6 +45,8 @@ def __init__(self, parent: DOMNode | None = None) -> None:
4145
# The nodes in the list
4246
self._nodes: list[Widget] = []
4347
self._nodes_set: set[Widget] = set()
48+
self._displayed_nodes: tuple[int, list[Widget]] = (-1, [])
49+
self._displayed_visible_nodes: tuple[int, list[Widget]] = (-1, [])
4450

4551
# We cache widgets by their IDs too for a quick lookup
4652
# Note that only widgets with IDs are cached like this, so
@@ -69,8 +75,6 @@ def updated(self) -> None:
6975
"""Mark the nodes as having been updated."""
7076
self._updates += 1
7177
node = None if self._parent is None else self._parent()
72-
if node is None:
73-
return
7478
while node is not None and (node := node._parent) is not None:
7579
node._nodes._updates += 1
7680

@@ -187,18 +191,29 @@ def __reversed__(self) -> Iterator[Widget]:
187191
return reversed(self._nodes)
188192

189193
@property
190-
def displayed(self) -> Iterable[Widget]:
194+
def displayed(self) -> Sequence[Widget]:
191195
"""Just the nodes where `display==True`."""
192-
for node in self._nodes:
193-
if node.display:
194-
yield node
196+
if self._displayed_nodes[0] != self._updates:
197+
self._displayed_nodes = (
198+
self._updates,
199+
list(filter(_display_getter, self._nodes)),
200+
)
201+
return self._displayed_nodes[1]
195202

196203
@property
197-
def displayed_reverse(self) -> Iterable[Widget]:
204+
def displayed_and_visible(self) -> Sequence[Widget]:
205+
"""Nodes with both `display==True` and `visible==True`."""
206+
if self._displayed_visible_nodes[0] != self._updates:
207+
self._displayed_nodes = (
208+
self._updates,
209+
list(filter(_visible_getter, self.displayed)),
210+
)
211+
return self._displayed_nodes[1]
212+
213+
@property
214+
def displayed_reverse(self) -> Iterator[Widget]:
198215
"""Just the nodes where `display==True`, in reverse order."""
199-
for node in reversed(self._nodes):
200-
if node.display:
201-
yield node
216+
return filter(_display_getter, reversed(self._nodes))
202217

203218
if TYPE_CHECKING:
204219

@@ -211,9 +226,11 @@ def __getitem__(self, index: slice) -> list[Widget]: ...
211226
def __getitem__(self, index: int | slice) -> Widget | list[Widget]:
212227
return self._nodes[index]
213228

214-
def __getattr__(self, key: str) -> object:
215-
if key in {"clear", "append", "pop", "insert", "remove", "extend"}:
216-
raise ReadOnlyError(
217-
"Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets"
218-
)
219-
raise AttributeError(key)
229+
if not TYPE_CHECKING:
230+
# This confused the type checker for some reason
231+
def __getattr__(self, key: str) -> object:
232+
if key in {"clear", "append", "pop", "insert", "remove", "extend"}:
233+
raise ReadOnlyError(
234+
"Widget.children is read-only: use Widget.mount(...) or Widget.remove(...) to add or remove widgets"
235+
)
236+
raise AttributeError(key)

src/textual/app.py

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

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

@@ -1554,6 +1556,8 @@ def size(self) -> Size:
15541556
Returns:
15551557
Size of the terminal.
15561558
"""
1559+
if self._size is not None:
1560+
return self._size
15571561
if self._driver is not None and self._driver._size is not None:
15581562
width, height = self._driver._size
15591563
else:
@@ -4136,6 +4140,7 @@ async def _on_key(self, event: events.Key) -> None:
41364140

41374141
async def _on_resize(self, event: events.Resize) -> None:
41384142
event.stop()
4143+
self._size = event.size
41394144
self._resize_event = event
41404145

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

src/textual/binding.py

Lines changed: 12 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,16 @@ 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+
"""A binding group causes the keys to be grouped under a single description."""
90+
91+
description: str = ""
92+
"""Description of the group."""
93+
94+
group: Group | None = None
95+
"""Optional binding group (used to group related bindings in the footer)."""
96+
8797
def parse_key(self) -> tuple[list[str], str]:
8898
"""Parse a key into a list of modifiers, and the actual key.
8999
@@ -151,6 +161,7 @@ def make_bindings(cls, bindings: Iterable[BindingType]) -> Iterable[Binding]:
151161
tooltip=binding.tooltip,
152162
id=binding.id,
153163
system=binding.system,
164+
group=binding.group,
154165
)
155166

156167

src/textual/content.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -405,13 +405,12 @@ def simplify(self) -> Content:
405405
Returns:
406406
Self.
407407
"""
408-
spans = self.spans
409-
if not spans:
408+
if not (spans := self._spans):
410409
return self
411-
last_span = Span(0, 0, Style())
410+
last_span = Span(-1, -1, "")
412411
new_spans: list[Span] = []
413412
changed: bool = False
414-
for span in self._spans:
413+
for span in spans:
415414
if span.start == last_span.end and span.style == last_span.style:
416415
last_span = new_spans[-1] = Span(last_span.start, span.end, span.style)
417416
changed = True
@@ -422,6 +421,19 @@ def simplify(self) -> Content:
422421
self._spans[:] = new_spans
423422
return self
424423

424+
def add_spans(self, spans: Sequence[Span]) -> Content:
425+
"""Adds spans to this Content instance.
426+
427+
Args:
428+
spans: A sequence of spans.
429+
430+
Returns:
431+
A Content instance.
432+
"""
433+
if spans:
434+
return Content(self.plain, [*self._spans, *spans], self._cell_length)
435+
return self
436+
425437
def __eq__(self, other: object) -> bool:
426438
"""Compares text only, so that markup doesn't effect sorting."""
427439
if isinstance(other, str):
@@ -693,7 +705,9 @@ def plain(self) -> str:
693705
@property
694706
def without_spans(self) -> Content:
695707
"""The content with no spans"""
696-
return Content(self.plain, [], self._cell_length)
708+
if self._spans:
709+
return Content(self.plain, [], self._cell_length)
710+
return self
697711

698712
@property
699713
def first_line(self) -> Content:
@@ -741,11 +755,7 @@ def __add__(self, other: Content | str) -> Content:
741755
for start, end, style in other._spans
742756
],
743757
],
744-
(
745-
self.cell_length + other._cell_length
746-
if other._cell_length is not None
747-
else None
748-
),
758+
(self.cell_length + other.cell_length),
749759
)
750760
return content
751761
return NotImplemented
@@ -1470,7 +1480,7 @@ def highlight_regex(
14701480
self,
14711481
highlight_regex: re.Pattern[str] | str,
14721482
*,
1473-
style: Style,
1483+
style: Style | str,
14741484
maximum_highlights: int | None = None,
14751485
) -> Content:
14761486
"""Apply a style to text that matches a regular expression.

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/dom.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import threading
1111
from functools import lru_cache, partial
1212
from inspect import getfile
13-
from operator import attrgetter
1413
from typing import (
1514
TYPE_CHECKING,
1615
Any,
@@ -408,6 +407,15 @@ def children(self) -> Sequence["Widget"]:
408407
"""
409408
return self._nodes
410409

410+
@property
411+
def displayed_children(self) -> Sequence[Widget]:
412+
"""The displayed children (where node.display==True).
413+
414+
Returns:
415+
A sequence of widgets.
416+
"""
417+
return self._nodes.displayed
418+
411419
@property
412420
def is_empty(self) -> bool:
413421
"""Are there no displayed children?"""
@@ -1215,15 +1223,6 @@ def ancestors(self) -> list[DOMNode]:
12151223
add_node(node)
12161224
return cast("list[DOMNode]", nodes)
12171225

1218-
@property
1219-
def displayed_children(self) -> list[Widget]:
1220-
"""The child nodes which will be displayed.
1221-
1222-
Returns:
1223-
A list of nodes.
1224-
"""
1225-
return list(filter(attrgetter("display"), self._nodes))
1226-
12271226
def watch(
12281227
self,
12291228
obj: DOMNode,

0 commit comments

Comments
 (0)