Skip to content

Commit 8a5cadd

Browse files
authored
Merge branch 'Textualize:main' into main
2 parents df9bbbc + fdae5e9 commit 8a5cadd

File tree

23 files changed

+566
-263
lines changed

23 files changed

+566
-263
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
2020
- Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048
2121
- Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048
2222
- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048
23+
- Added `Header.format_title` and `App.format_title` for easier customization of title in the Header https://github.com/Textualize/textual/pull/6051
2324
- 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
2429

2530
### Changed
2631

2732
- Breaking change: The `renderable` property on the `Static` widget has been changed to `content`. https://github.com/Textualize/textual/pull/6041
33+
- Breaking change: `HeaderTitle` widget is now a static, with no `text` and `sub_text` reactives https://github.com/Textualize/textual/pull/6051
2834
- Breaking change: Renamed `Label` constructor argument `renderable` to `content` for consistency https://github.com/Textualize/textual/pull/6045
2935
- Breaking change: Optimization to line API to avoid applying background styles to widget content. In practice this means that you can no longer rely on blank Segments automatically getting the background color.
3036

docs/how-to/style-inline-apps.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ The following app displays the the current time (and keeps it up to date).
1919

2020
With Textual's default settings, this clock will be displayed in 5 lines; 3 for the digits and 2 for a top and bottom border.
2121

22+
!!! note
23+
24+
Textual also adds a blank line above inline apps for padding.
25+
To remove this default padding, you can set `INLINE_PADDING = 0` on your app class.
26+
2227
You can change the height or the border with CSS and the `:inline` pseudo-selector, which only matches rules in inline mode.
2328
Let's update this app to remove the default border, and increase the height:
2429

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: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
from textual.binding import Binding, BindingsMap, BindingType, Keymap
9595
from textual.command import CommandListItem, CommandPalette, Provider, SimpleProvider
9696
from textual.compose import compose
97+
from textual.content import Content
9798
from textual.css.errors import StylesheetError
9899
from textual.css.query import NoMatches
99100
from textual.css.stylesheet import RulesMap, Stylesheet
@@ -813,6 +814,8 @@ def __init__(
813814
self._resize_event: events.Resize | None = None
814815
"""A pending resize event, sent on idle."""
815816

817+
self._size: Size | None = None
818+
816819
self._css_update_count: int = 0
817820
"""Incremented when CSS is invalidated."""
818821

@@ -971,6 +974,27 @@ def clipboard(self) -> str:
971974
"""
972975
return self._clipboard
973976

977+
def format_title(self, title: str, sub_title: str) -> Content:
978+
"""Format the title for display.
979+
980+
Args:
981+
title: The title.
982+
sub_title: The sub title.
983+
984+
Returns:
985+
Content instance with title and subtitle.
986+
"""
987+
title_content = Content(title)
988+
sub_title_content = Content(sub_title)
989+
if sub_title_content:
990+
return Content.assemble(
991+
title_content,
992+
(" — ", "dim"),
993+
sub_title_content.stylize("dim"),
994+
)
995+
else:
996+
return title_content
997+
974998
@contextmanager
975999
def batch_update(self) -> Generator[None, None, None]:
9761000
"""A context manager to suspend all repaints until the end of the batch."""
@@ -1532,6 +1556,8 @@ def size(self) -> Size:
15321556
Returns:
15331557
Size of the terminal.
15341558
"""
1559+
if self._size is not None:
1560+
return self._size
15351561
if self._driver is not None and self._driver._size is not None:
15361562
width, height = self._driver._size
15371563
else:
@@ -4114,6 +4140,7 @@ async def _on_key(self, event: events.Key) -> None:
41144140

41154141
async def _on_resize(self, event: events.Resize) -> None:
41164142
event.stop()
4143+
self._size = event.size
41174144
self._resize_event = event
41184145

41194146
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: 26 additions & 16 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:
@@ -734,18 +748,14 @@ def __add__(self, other: Content | str) -> Content:
734748
offset = len(self.plain)
735749
content = Content(
736750
self.plain + other.plain,
737-
[
738-
*self._spans,
739-
*[
751+
(
752+
self._spans
753+
+ [
740754
Span(start + offset, end + offset, style)
741755
for start, end, style in other._spans
742-
],
743-
],
744-
(
745-
self.cell_length + other._cell_length
746-
if other._cell_length is not None
747-
else None
756+
]
748757
),
758+
(self.cell_length + other.cell_length),
749759
)
750760
return content
751761
return NotImplemented
@@ -1083,7 +1093,7 @@ def stylize(
10831093
return self
10841094
return Content(
10851095
self.plain,
1086-
[*self._spans, Span(start, length if length < end else end, style)],
1096+
self._spans + [Span(start, length if length < end else end, style)],
10871097
)
10881098

10891099
def stylize_before(
@@ -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/demo/widgets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -587,7 +587,7 @@ class Switches(containers.VerticalGroup):
587587
SWITCHES_MD = """\
588588
## Switches
589589
590-
Functionally almost identical to a Checkbox, but more displays more prominently in the UI.
590+
Functionally almost identical to a Checkbox, but displays more prominently in the UI.
591591
"""
592592
DEFAULT_CSS = """\
593593
Switches {

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)