Skip to content

content simplify #6023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

# Unreleased

### Added

- Added `Content.simplify` https://github.com/Textualize/textual/pull/6023
- Added `textual.reactive.Initialize` https://github.com/Textualize/textual/pull/6023

## [5.2.0] - 2025-08-01

### Added
Expand Down
5 changes: 4 additions & 1 deletion src/textual/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ def count_parameters(func: Callable) -> int:
param_count = _count_parameters(func) - 1
else:
param_count = _count_parameters(func)
func._param_count = param_count
try:
func._param_count = param_count
except TypeError:
pass
return param_count


Expand Down
1 change: 1 addition & 0 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ def add_widget(
)
)
widget.set_reactive(Widget.scroll_y, new_scroll_y)
widget.set_reactive(Widget.scroll_target_y, new_scroll_y)
widget.vertical_scrollbar._reactive_position = new_scroll_y

if visible_only:
Expand Down
24 changes: 24 additions & 0 deletions src/textual/_slug.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,27 @@ def slug(self, text: str) -> str:
if used:
slugged = f"{slugged}-{used}"
return slugged


VALID_ID_CHARACTERS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-")


def slug_for_tcss_id(text: str) -> str:
"""Produce a slug usable as a TCSS id from the given text.

Args:
text: Text.

Returns:
A slugified version of text suitable for use as a TCSS id.
"""
is_valid = VALID_ID_CHARACTERS.__contains__
slug = "".join(
(character if is_valid(character) else "{:x}".format(ord(character)))
for character in text.casefold().replace(" ", "-")
)
if not slug:
return "_"
if slug[0].isdecimal():
return f"_{slug}"
return slug
38 changes: 36 additions & 2 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,36 @@ def assemble(
text_append(end)
return cls("".join(text), spans)

def simplify(self) -> Content:
"""Simplify spans by joining contiguous spans together.

This can produce faster renders but typically only worth it if you have appended a
large number of Content instances together.

Note that this this modifies the Content instance in-place, which might appear
to violate the immutability constraints, but it will not change the rendered output,
nor its hash.

Returns:
Self.
"""
spans = self.spans
if not spans:
return self
last_span = Span(0, 0, Style())
new_spans: list[Span] = []
changed: bool = False
for span in self._spans:
if span.start == last_span.end and span.style == last_span.style:
last_span = new_spans[-1] = Span(last_span.start, span.end, span.style)
changed = True
else:
new_spans.append(span)
last_span = span
if changed:
self._spans[:] = new_spans
return self

def __eq__(self, other: object) -> bool:
"""Compares text only, so that markup doesn't effect sorting."""
if isinstance(other, str):
Expand Down Expand Up @@ -528,7 +558,6 @@ def get_span(y: int) -> tuple[int, int] | None:
return None

for y, line in enumerate(self.split(allow_blank=True)):

if post_style is not None:
line = line.stylize(post_style)

Expand Down Expand Up @@ -1201,6 +1230,12 @@ def render_segments(
]
return segments

def __rich__(self):
"""Allow Content to be rendered with rich.print."""
from rich.segment import Segments

return Segments(self.render_segments(Style(), "\n"))

def _divide_spans(self, offsets: tuple[int, ...]) -> list[tuple[Span, int, int]]:
"""Divide content from a list of offset to cut.

Expand Down Expand Up @@ -1568,7 +1603,6 @@ def to_strip(self, style: Style) -> tuple[list[Segment], int]:
def _apply_link_style(
self, link_style: RichStyle, segments: list[Segment]
) -> list[Segment]:

_Segment = Segment
segments = [
_Segment(
Expand Down
24 changes: 7 additions & 17 deletions src/textual/getters.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,23 +54,17 @@ def __init__(self, selector: str) -> None:
Args:
selector: A TCSS selector, e.g. "#mywidget"
"""
self.selector = selector
self.expect_type = Widget

@overload
def __init__(self, selector: type[QueryType]) -> None:
self.selector = selector.__name__
self.expect_type = selector
def __init__(self, selector: type[QueryType]) -> None: ...

@overload
def __init__(self, selector: str, expect_type: type[QueryType]) -> None:
self.selector = selector
self.expect_type = expect_type
def __init__(self, selector: str, expect_type: type[QueryType]) -> None: ...

@overload
def __init__(self, selector: type[QueryType], expect_type: type[QueryType]) -> None:
self.selector = selector.__name__
self.expect_type = expect_type
def __init__(
self, selector: type[QueryType], expect_type: type[QueryType]
) -> None: ...

def __init__(
self,
Expand Down Expand Up @@ -140,14 +134,10 @@ def on_mount(self) -> None:
expect_type: type[Widget]

@overload
def __init__(self, child_id: str) -> None:
self.child_id = child_id
self.expect_type = Widget
def __init__(self, child_id: str) -> None: ...

@overload
def __init__(self, child_id: str, expect_type: type[QueryType]) -> None:
self.child_id = child_id
self.expect_type = expect_type
def __init__(self, child_id: str, expect_type: type[QueryType]) -> None: ...

def __init__(
self,
Expand Down
35 changes: 31 additions & 4 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ class TooManyComputesError(ReactiveError):
"""Raised when an attribute has public and private compute methods."""


class Initialize(Generic[ReactiveType]):
"""Initialize a reactive by calling a method parent object.
Example:
```python
class InitializeApp(App):
def get_names(self) -> list[str]:
return ["foo", "bar", "baz"]
# The `names` property will call `get_names` to get its default when first referenced.
names = reactive(Initialize(get_names))
```
"""

def __init__(self, callback: Callable[[ReactableType], ReactiveType]) -> None:
self.callback = callback

def __call__(self, obj: ReactableType) -> ReactiveType:
return self.callback(obj)


async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None:
"""Coroutine to await an awaitable returned from a watcher"""
_rich_traceback_omit = True
Expand Down Expand Up @@ -118,7 +141,7 @@ class Reactive(Generic[ReactiveType]):

def __init__(
self,
default: ReactiveType | Callable[[], ReactiveType],
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
*,
layout: bool = False,
repaint: bool = True,
Expand Down Expand Up @@ -190,7 +213,11 @@ def _initialize_reactive(self, obj: Reactable, name: str) -> None:
else:
default_or_callable = self._default
default = (
default_or_callable()
(
default_or_callable(obj)
if isinstance(default_or_callable, Initialize)
else default_or_callable()
)
if callable(default_or_callable)
else default_or_callable
)
Expand Down Expand Up @@ -421,7 +448,7 @@ class reactive(Reactive[ReactiveType]):

def __init__(
self,
default: ReactiveType | Callable[[], ReactiveType],
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
*,
layout: bool = False,
repaint: bool = True,
Expand Down Expand Up @@ -456,7 +483,7 @@ class var(Reactive[ReactiveType]):

def __init__(
self,
default: ReactiveType | Callable[[], ReactiveType],
default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType],
init: bool = True,
always_update: bool = False,
bindings: bool = False,
Expand Down
4 changes: 3 additions & 1 deletion src/textual/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ def to_strips(
height: int | None,
style: Style,
*,
apply_selection: bool = True,
pad: bool = False,
post_style: Style | None = None,
) -> list[Strip]:
Expand All @@ -207,6 +208,7 @@ def to_strips(
width: Desired width (in cells).
height: Desired height (in lines) or `None` for no limit.
style: A (Visual) Style instance.
apply_selection: Automatically apply selection styles?
pad: Pad to desired width?
post_style: Optional Style to apply to strips after rendering.
Expand All @@ -229,7 +231,7 @@ def to_strips(
RenderOptions(
widget._get_style,
widget.styles,
selection,
selection if apply_selection else None,
selection_style,
),
)
Expand Down
6 changes: 4 additions & 2 deletions src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from rich.text import Text
from typing_extensions import TypeAlias

from textual._slug import TrackedSlugs, slug
from textual._slug import TrackedSlugs, slug_for_tcss_id
from textual.app import ComposeResult
from textual.await_complete import AwaitComplete
from textual.containers import Horizontal, Vertical, VerticalScroll
Expand Down Expand Up @@ -1271,7 +1271,9 @@ def _parse_markdown(self, tokens: Iterable[Token]) -> Iterable[MarkdownBlock]:
elif token_type.endswith("_close"):
block = stack.pop()
if token.type == "heading_close":
block.id = f"heading-{slug(block._content.plain)}-{id(block)}"
block.id = (
f"heading-{slug_for_tcss_id(block._content.plain)}-{id(block)}"
)
if stack:
stack[-1]._blocks.append(block)
else:
Expand Down
8 changes: 8 additions & 0 deletions tests/test_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,11 @@ def test_split_and_tabs():
content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans)
widget = Widget()
content.render_strips(0, None, Style(), RenderOptions(widget._get_style, {}))


def test_simplify():
"""Test simplify joins spans."""
content = Content.from_markup("[bold]Foo[/][bold]Bar[/]")
assert content.spans == [Span(0, 3, "bold"), Span(3, 6, "bold")]
content.simplify()
assert content.spans == [Span(0, 6, "bold")]
18 changes: 17 additions & 1 deletion tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,29 @@
from textual.app import App, ComposeResult
from textual.message import Message
from textual.message_pump import MessagePump
from textual.reactive import Reactive, TooManyComputesError, reactive, var
from textual.reactive import Initialize, Reactive, TooManyComputesError, reactive, var
from textual.widget import Widget

OLD_VALUE = 5_000
NEW_VALUE = 1_000_000


async def test_initialize():
"""Test that the default accepts an Initialize instance."""

class InitializeApp(App):

def get_names(self) -> list[str]:
return ["foo", "bar", "baz"]

names = reactive(Initialize(get_names))

app = InitializeApp()

async with app.run_test():
assert app.names == ["foo", "bar", "baz"]


async def test_watch():
"""Test that changes to a watched reactive attribute happen immediately."""

Expand Down
24 changes: 23 additions & 1 deletion tests/test_slug.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest

from textual._slug import TrackedSlugs, slug
from textual._slug import TrackedSlugs, slug, slug_for_tcss_id
from textual.dom import check_identifiers


@pytest.mark.xdist_group("group1")
Expand Down Expand Up @@ -64,3 +65,24 @@ def tracker() -> TrackedSlugs:
def test_tracked_slugs(tracker: TrackedSlugs, text: str, expected: str) -> None:
"""The tracked slugging class should produce the expected slugs."""
assert tracker.slug(text) == expected


@pytest.mark.parametrize(
"text, expected",
[
("", "_"),
(" ", "-"),
("5", "_5"),
("a", "a"),
("hello world", "hello-world"),
("🙂", "_1f642"),
("🙂🙂", "_1f6421f642"),
("Foo🙂", "foo1f642"),
("ß", "ss"),
],
)
def test_slug_for_tcss_id(text: str, expected: str) -> None:
"""Test the slug_for_tcss_id"""
slug = slug_for_tcss_id(text)
assert slug == expected
check_identifiers(slug)
Loading