Skip to content

Commit e4a2bfa

Browse files
authored
Merge pull request #6023 from Textualize/content-simplify
content simplify
2 parents ac1d3ef + 73f0bfc commit e4a2bfa

File tree

12 files changed

+165
-29
lines changed

12 files changed

+165
-29
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
# Unreleased
9+
10+
### Added
11+
12+
- Added `Content.simplify` https://github.com/Textualize/textual/pull/6023
13+
- Added `textual.reactive.Initialize` https://github.com/Textualize/textual/pull/6023
14+
815
## [5.2.0] - 2025-08-01
916

1017
### Added

src/textual/_callback.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ def count_parameters(func: Callable) -> int:
3030
param_count = _count_parameters(func) - 1
3131
else:
3232
param_count = _count_parameters(func)
33-
func._param_count = param_count
33+
try:
34+
func._param_count = param_count
35+
except TypeError:
36+
pass
3437
return param_count
3538

3639

src/textual/_compositor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,7 @@ def add_widget(
614614
)
615615
)
616616
widget.set_reactive(Widget.scroll_y, new_scroll_y)
617+
widget.set_reactive(Widget.scroll_target_y, new_scroll_y)
617618
widget.vertical_scrollbar._reactive_position = new_scroll_y
618619

619620
if visible_only:

src/textual/_slug.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,27 @@ def slug(self, text: str) -> str:
114114
if used:
115115
slugged = f"{slugged}-{used}"
116116
return slugged
117+
118+
119+
VALID_ID_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")
120+
121+
122+
def slug_for_tcss_id(text: str) -> str:
123+
"""Produce a slug usable as a TCSS id from the given text.
124+
125+
Args:
126+
text: Text.
127+
128+
Returns:
129+
A slugified version of text suitable for use as a TCSS id.
130+
"""
131+
is_valid = VALID_ID_CHARACTERS.__contains__
132+
slug = "".join(
133+
(character if is_valid(character) else "{:x}".format(ord(character)))
134+
for character in text.casefold().replace(" ", "-")
135+
)
136+
if not slug:
137+
return "_"
138+
if slug[0].isdecimal():
139+
return f"_{slug}"
140+
return slug

src/textual/content.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,36 @@ def assemble(
395395
text_append(end)
396396
return cls("".join(text), spans)
397397

398+
def simplify(self) -> Content:
399+
"""Simplify spans by joining contiguous spans together.
400+
401+
This can produce faster renders but typically only worth it if you have appended a
402+
large number of Content instances together.
403+
404+
Note that this this modifies the Content instance in-place, which might appear
405+
to violate the immutability constraints, but it will not change the rendered output,
406+
nor its hash.
407+
408+
Returns:
409+
Self.
410+
"""
411+
spans = self.spans
412+
if not spans:
413+
return self
414+
last_span = Span(0, 0, Style())
415+
new_spans: list[Span] = []
416+
changed: bool = False
417+
for span in self._spans:
418+
if span.start == last_span.end and span.style == last_span.style:
419+
last_span = new_spans[-1] = Span(last_span.start, span.end, span.style)
420+
changed = True
421+
else:
422+
new_spans.append(span)
423+
last_span = span
424+
if changed:
425+
self._spans[:] = new_spans
426+
return self
427+
398428
def __eq__(self, other: object) -> bool:
399429
"""Compares text only, so that markup doesn't effect sorting."""
400430
if isinstance(other, str):
@@ -528,7 +558,6 @@ def get_span(y: int) -> tuple[int, int] | None:
528558
return None
529559

530560
for y, line in enumerate(self.split(allow_blank=True)):
531-
532561
if post_style is not None:
533562
line = line.stylize(post_style)
534563

@@ -1201,6 +1230,12 @@ def render_segments(
12011230
]
12021231
return segments
12031232

1233+
def __rich__(self):
1234+
"""Allow Content to be rendered with rich.print."""
1235+
from rich.segment import Segments
1236+
1237+
return Segments(self.render_segments(Style(), "\n"))
1238+
12041239
def _divide_spans(self, offsets: tuple[int, ...]) -> list[tuple[Span, int, int]]:
12051240
"""Divide content from a list of offset to cut.
12061241
@@ -1568,7 +1603,6 @@ def to_strip(self, style: Style) -> tuple[list[Segment], int]:
15681603
def _apply_link_style(
15691604
self, link_style: RichStyle, segments: list[Segment]
15701605
) -> list[Segment]:
1571-
15721606
_Segment = Segment
15731607
segments = [
15741608
_Segment(

src/textual/getters.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -54,23 +54,17 @@ def __init__(self, selector: str) -> None:
5454
Args:
5555
selector: A TCSS selector, e.g. "#mywidget"
5656
"""
57-
self.selector = selector
58-
self.expect_type = Widget
5957

6058
@overload
61-
def __init__(self, selector: type[QueryType]) -> None:
62-
self.selector = selector.__name__
63-
self.expect_type = selector
59+
def __init__(self, selector: type[QueryType]) -> None: ...
6460

6561
@overload
66-
def __init__(self, selector: str, expect_type: type[QueryType]) -> None:
67-
self.selector = selector
68-
self.expect_type = expect_type
62+
def __init__(self, selector: str, expect_type: type[QueryType]) -> None: ...
6963

7064
@overload
71-
def __init__(self, selector: type[QueryType], expect_type: type[QueryType]) -> None:
72-
self.selector = selector.__name__
73-
self.expect_type = expect_type
65+
def __init__(
66+
self, selector: type[QueryType], expect_type: type[QueryType]
67+
) -> None: ...
7468

7569
def __init__(
7670
self,
@@ -140,14 +134,10 @@ def on_mount(self) -> None:
140134
expect_type: type[Widget]
141135

142136
@overload
143-
def __init__(self, child_id: str) -> None:
144-
self.child_id = child_id
145-
self.expect_type = Widget
137+
def __init__(self, child_id: str) -> None: ...
146138

147139
@overload
148-
def __init__(self, child_id: str, expect_type: type[QueryType]) -> None:
149-
self.child_id = child_id
150-
self.expect_type = expect_type
140+
def __init__(self, child_id: str, expect_type: type[QueryType]) -> None: ...
151141

152142
def __init__(
153143
self,

src/textual/reactive.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ class TooManyComputesError(ReactiveError):
5656
"""Raised when an attribute has public and private compute methods."""
5757

5858

59+
class Initialize(Generic[ReactiveType]):
60+
"""Initialize a reactive by calling a method parent object.
61+
62+
Example:
63+
```python
64+
class InitializeApp(App):
65+
66+
def get_names(self) -> list[str]:
67+
return ["foo", "bar", "baz"]
68+
69+
# The `names` property will call `get_names` to get its default when first referenced.
70+
names = reactive(Initialize(get_names))
71+
```
72+
73+
"""
74+
75+
def __init__(self, callback: Callable[[ReactableType], ReactiveType]) -> None:
76+
self.callback = callback
77+
78+
def __call__(self, obj: ReactableType) -> ReactiveType:
79+
return self.callback(obj)
80+
81+
5982
async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None:
6083
"""Coroutine to await an awaitable returned from a watcher"""
6184
_rich_traceback_omit = True
@@ -118,7 +141,7 @@ class Reactive(Generic[ReactiveType]):
118141

119142
def __init__(
120143
self,
121-
default: ReactiveType | Callable[[], ReactiveType],
144+
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
122145
*,
123146
layout: bool = False,
124147
repaint: bool = True,
@@ -190,7 +213,11 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None:
190213
else:
191214
default_or_callable = self._default
192215
default = (
193-
default_or_callable()
216+
(
217+
default_or_callable(obj)
218+
if isinstance(default_or_callable, Initialize)
219+
else default_or_callable()
220+
)
194221
if callable(default_or_callable)
195222
else default_or_callable
196223
)
@@ -421,7 +448,7 @@ class reactive(Reactive[ReactiveType]):
421448

422449
def __init__(
423450
self,
424-
default: ReactiveType | Callable[[], ReactiveType],
451+
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
425452
*,
426453
layout: bool = False,
427454
repaint: bool = True,
@@ -456,7 +483,7 @@ class var(Reactive[ReactiveType]):
456483

457484
def __init__(
458485
self,
459-
default: ReactiveType | Callable[[], ReactiveType],
486+
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
460487
init: bool = True,
461488
always_update: bool = False,
462489
bindings: bool = False,

src/textual/visual.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def to_strips(
196196
height: int | None,
197197
style: Style,
198198
*,
199+
apply_selection: bool = True,
199200
pad: bool = False,
200201
post_style: Style | None = None,
201202
) -> list[Strip]:
@@ -207,6 +208,7 @@ def to_strips(
207208
width: Desired width (in cells).
208209
height: Desired height (in lines) or `None` for no limit.
209210
style: A (Visual) Style instance.
211+
apply_selection: Automatically apply selection styles?
210212
pad: Pad to desired width?
211213
post_style: Optional Style to apply to strips after rendering.
212214
@@ -229,7 +231,7 @@ def to_strips(
229231
RenderOptions(
230232
widget._get_style,
231233
widget.styles,
232-
selection,
234+
selection if apply_selection else None,
233235
selection_style,
234236
),
235237
)

src/textual/widgets/_markdown.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from rich.text import Text
1414
from typing_extensions import TypeAlias
1515

16-
from textual._slug import TrackedSlugs, slug
16+
from textual._slug import TrackedSlugs, slug_for_tcss_id
1717
from textual.app import ComposeResult
1818
from textual.await_complete import AwaitComplete
1919
from textual.containers import Horizontal, Vertical, VerticalScroll
@@ -1267,7 +1267,9 @@ def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]:
12671267
elif token_type.endswith("_close"):
12681268
block = stack.pop()
12691269
if token.type == "heading_close":
1270-
block.id = f"heading-{slug(block._content.plain)}-{id(block)}"
1270+
block.id = (
1271+
f"heading-{slug_for_tcss_id(block._content.plain)}-{id(block)}"
1272+
)
12711273
if stack:
12721274
stack[-1]._blocks.append(block)
12731275
else:

tests/test_content.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,11 @@ def test_split_and_tabs():
286286
content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans)
287287
widget = Widget()
288288
content.render_strips(0, None, Style(), RenderOptions(widget._get_style, {}))
289+
290+
291+
def test_simplify():
292+
"""Test simplify joins spans."""
293+
content = Content.from_markup("[bold]Foo[/][bold]Bar[/]")
294+
assert content.spans == [Span(0, 3, "bold"), Span(3, 6, "bold")]
295+
content.simplify()
296+
assert content.spans == [Span(0, 6, "bold")]

0 commit comments

Comments
 (0)