From 2d9f2bb31942e9a2c7c382bfd18da09e1fd55b9b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Nov 2025 22:00:55 +0000 Subject: [PATCH 1/7] optimizations for Content and Style --- src/textual/_compositor.py | 6 +-- src/textual/content.py | 108 ++++++++++++++++++++++++++++++++----- src/textual/style.py | 4 ++ src/textual/widget.py | 3 +- 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 47ad49719e..73fea51cc1 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -695,9 +695,9 @@ def add_widget( widget.container_size.height - widget.scrollbar_size_horizontal ) - 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 + widget.scroll_y = new_scroll_y + widget.scroll_target_y = new_scroll_y + widget.vertical_scrollbar.position = new_scroll_y if visible: # Add any scrollbars diff --git a/src/textual/content.py b/src/textual/content.py index 8e2c3c716a..f296ecc7c0 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -106,6 +106,27 @@ def extend(self, cells: int) -> "Span": return Span(start, end + cells, style) return self + def shift(self, distance: int) -> "Span": + """Shift a span a given distance. + + Note that the start offset is clamped to 0. + The end offset is not clamped, as it is assumed this has already been checked by the caller. + + Args: + distance: Number of characters to move. + + Returns: + New Span. + """ + if distance < 0: + start, end, style = self + return Span( + offset if (offset := start + distance) > 0 else 0, end + distance, style + ) + else: + start, end, style = self + return Span(start + distance, end + distance, style) + @rich.repr.auto @total_ordering @@ -126,6 +147,7 @@ def __init__( text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, + strip_control_codes: bool = True, ) -> None: """ Initialize a Content object. @@ -134,8 +156,12 @@ def __init__( text: text content. spans: Optional list of spans. cell_length: Cell length of text if known, otherwise `None`. + strip_control_codes: Strip control codes that may break output? """ - self._text: str = _strip_control_codes(text) + if strip_control_codes and text: + self._text: str = _strip_control_codes(text) + else: + self._text = text self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length self._optimal_width_cache: int | None = None @@ -147,6 +173,8 @@ def __init__( self._split_cache: FIFOCache[tuple[str, bool, bool], list[Content]] | None = ( None ) + # If there are 1 or 0 spans, it can't be simplified further + self._simplified = len(self._spans) <= 1 def __str__(self) -> str: return self._text @@ -321,6 +349,7 @@ def styled( text: str, style: Style | str = "", cell_length: int | None = None, + strip_control_codes: bool = True, ) -> Content: """Create a Content instance from text and an optional style. @@ -328,18 +357,27 @@ def styled( text: String content. style: Desired style. cell_length: Cell length of text if known, otherwise `None`. + strip_control_codes: Strip control codes that may break output. Returns: New Content instance. """ if not text: return Content("") - new_content = cls(text, [Span(0, len(text), style)] if style else None) + new_content = cls( + text, + [Span(0, len(text), style)] if style else None, + cell_length, + strip_control_codes=strip_control_codes, + ) return new_content @classmethod def assemble( - cls, *parts: str | Content | tuple[str, str | Style], end: str = "" + cls, + *parts: str | Content | tuple[str, str | Style], + end: str = "", + strip_control_codes: bool = True, ) -> Content: """Construct new content from string, content, or tuples of (TEXT, STYLE). @@ -359,6 +397,7 @@ def assemble( *parts: Parts to join to gether. A *part* may be a simple string, another Content instance, or tuple containing text and a style. end: Optional end to the Content. + strip_control_codes: Strip control codes that may break output. """ text: list[str] = [] spans: list[Span] = [] @@ -390,7 +429,7 @@ def assemble( position += len(part.plain) if end: text_append(end) - return cls("".join(text), spans) + return cls("".join(text), spans, strip_control_codes=strip_control_codes) def simplify(self) -> Content: """Simplify spans by joining contiguous spans together. @@ -405,7 +444,7 @@ def simplify(self) -> Content: Returns: Self. """ - if not (spans := self._spans): + if not (spans := self._spans) or self._simplified: return self last_span = Span(-1, -1, "") new_spans: list[Span] = [] @@ -419,6 +458,7 @@ def simplify(self) -> Content: last_span = span if changed: self._spans[:] = new_spans + self._simplified = True return self def add_spans(self, spans: Sequence[Span]) -> Content: @@ -431,7 +471,12 @@ def add_spans(self, spans: Sequence[Span]) -> Content: A Content instance. """ if spans: - return Content(self.plain, [*self._spans, *spans], self._cell_length) + return Content( + self.plain, + [*self._spans, *spans], + self._cell_length, + strip_control_codes=False, + ) return self def __eq__(self, other: object) -> bool: @@ -706,7 +751,7 @@ def plain(self) -> str: def without_spans(self) -> Content: """The content with no spans""" if self._spans: - return Content(self.plain, [], self._cell_length) + return Content(self.plain, [], self._cell_length, strip_control_codes=False) return self @property @@ -726,6 +771,7 @@ def get_text_at(offset: int) -> "Content": for start, end, style in self._spans if end > offset >= start ], + strip_control_codes=False, ) return content @@ -734,8 +780,24 @@ def get_text_at(offset: int) -> "Content": else: start, stop, step = slice.indices(len(self.plain)) if step == 1: - lines = self.divide([start, stop]) - return lines[1] + if start == 0: + if stop >= len(self.plain): + return self + text = self.plain[:stop] + return Content( + text, + self._trim_spans(text, self._spans), + strip_control_codes=False, + ) + else: + text = self.plain[start:stop] + spans = [ + span.shift(-start) for span in self._spans if span.end > start + ] + return Content( + text, self._trim_spans(text, spans), strip_control_codes=False + ) + else: # This would be a bit of work to implement efficiently # For now, its not required @@ -743,7 +805,7 @@ def get_text_at(offset: int) -> "Content": def __add__(self, other: Content | str) -> Content: if isinstance(other, str): - return Content(self._text + other, self._spans) + return Content(self._text + other, self._spans, strip_control_codes=False) if isinstance(other, Content): offset = len(self.plain) content = Content( @@ -801,6 +863,7 @@ def append(self, content: Content | str) -> Content: if self._cell_length is None else self._cell_length + cell_len(content) ), + strip_control_codes=False, ) return Content("").join([self, content]) @@ -836,12 +899,20 @@ def iter_content() -> Iterable[Content]: """Iterate the lines, optionally inserting the separator.""" if self.plain: for last, line in loop_last(lines): - yield line if isinstance(line, Content) else Content(line) + yield ( + line + if isinstance(line, Content) + else Content(line, strip_control_codes=False) + ) if not last: yield self else: for line in lines: - yield line if isinstance(line, Content) else Content(line) + yield ( + line + if isinstance(line, Content) + else Content(line, strip_control_codes=False) + ) extend_text = text.extend extend_spans = spans.extend @@ -935,13 +1006,16 @@ def truncate( if pad and length < max_width: spaces = max_width - length text = f"{self.plain}{' ' * spaces}" + return Content(text, spans, max_width, strip_control_codes=False) elif length > max_width: if ellipsis and max_width: text = set_cell_size(self.plain, max_width - 1) + "…" else: text = set_cell_size(self.plain, max_width) spans = self._trim_spans(text, self._spans) - return Content(text, spans) + return Content(text, spans, max_width, strip_control_codes=False) + else: + return self def pad_left(self, count: int, character: str = " ") -> Content: """Pad the left with a given character. @@ -962,6 +1036,7 @@ def pad_left(self, count: int, character: str = " ") -> Content: text, spans, None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return content @@ -987,6 +1062,7 @@ def extend_right(self, count: int, character: str = " ") -> Content: for span in self._spans ], None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return self @@ -1003,6 +1079,7 @@ def pad_right(self, count: int, character: str = " ") -> Content: f"{self.plain}{character * count}", self._spans, None if self._cell_length is None else self._cell_length + count, + strip_control_codes=False, ) return self @@ -1029,6 +1106,7 @@ def pad(self, left: int, right: int, character: str = " ") -> Content: text, spans, None if self._cell_length is None else self._cell_length + left + right, + strip_control_codes=False, ) return content @@ -1114,6 +1192,8 @@ def stylize( return Content( self.plain, self._spans + [Span(start, length if length < end else end, style)], + self._cell_length, + strip_control_codes=False, ) def stylize_before( @@ -1146,6 +1226,8 @@ def stylize_before( return Content( self.plain, [Span(start, length if length < end else end, style), *self._spans], + self._cell_length, + strip_control_codes=False, ) def render( diff --git a/src/textual/style.py b/src/textual/style.py index 5168b40408..26bfa49690 100644 --- a/src/textual/style.py +++ b/src/textual/style.py @@ -239,6 +239,10 @@ def markup_tag(self) -> str: @lru_cache(maxsize=1024 * 4) def __add__(self, other: object | None) -> Style: if isinstance(other, Style): + if self._is_null: + return other + if other._is_null: + return self ( background, foreground, diff --git a/src/textual/widget.py b/src/textual/widget.py index c6aae7a478..c7af2a6d2a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1896,7 +1896,7 @@ def watch_hover_style( ) -> None: # TODO: This will cause the widget to refresh, even when there are no links # Can we avoid this? - if self.auto_links: + if self.auto_links and not self.app.mouse_captured: self.highlight_link_id = hover_style.link_id def watch_scroll_x(self, old_value: float, new_value: float) -> None: @@ -4270,6 +4270,7 @@ def refresh( Returns: The `Widget` instance. """ + if layout and not self._layout_required: self._layout_required = True self._layout_updates += 1 From 01ea0516b85850236d3f1af19e97f26d498dc931 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Nov 2025 22:07:32 +0000 Subject: [PATCH 2/7] content improvements --- src/textual/content.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index f296ecc7c0..7d11f857ea 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -363,7 +363,7 @@ def styled( New Content instance. """ if not text: - return Content("") + return EMPTY_CONTENT new_content = cls( text, [Span(0, len(text), style)] if style else None, @@ -865,7 +865,7 @@ def append(self, content: Content | str) -> Content: ), strip_control_codes=False, ) - return Content("").join([self, content]) + return EMPTY_CONTENT.join([self, content]) def append_text(self, text: str, style: Style | str = "") -> Content: """Append text give as a string, with an optional style. @@ -1165,7 +1165,7 @@ def right_crop(self, amount: int = 1) -> Content: ] text = self.plain[:-amount] length = None if self._cell_length is None else self._cell_length - amount - return Content(text, spans, length) + return Content(text, spans, length, strip_control_codes=False) def stylize( self, style: Style | str, start: int = 0, end: int | None = None @@ -1575,7 +1575,7 @@ def expand_tabs(self, tab_size: int = 8) -> Content: cell_position += part.cell_length append(part) - content = Content("").join(new_text) + content = EMPTY_CONTENT.join(new_text) return content def highlight_regex( @@ -1613,7 +1613,7 @@ def highlight_regex( and (count := count + 1) >= maximum_highlights ): break - return Content(self._text, spans) + return Content(self._text, spans, cell_length=self._cell_length) class _FormattedLine: From 9f6ffb2073493f8430a50f63b09733be08d3554d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Nov 2025 22:18:41 +0000 Subject: [PATCH 3/7] simplify --- src/textual/content.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index 7d11f857ea..bb1f8f1164 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -158,10 +158,10 @@ def __init__( cell_length: Cell length of text if known, otherwise `None`. strip_control_codes: Strip control codes that may break output? """ - if strip_control_codes and text: - self._text: str = _strip_control_codes(text) - else: - self._text = text + + self._text: str = ( + _strip_control_codes(text) if strip_control_codes and text else text + ) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length self._optimal_width_cache: int | None = None @@ -817,7 +817,11 @@ def __add__(self, other: Content | str) -> Content: for start, end, style in other._spans ] ), - (self.cell_length + other.cell_length), + ( + None + if self._cell_length is not None + else (self.cell_length + other.cell_length) + ), ) return content return NotImplemented From 4e79675fc89a6b1c564688c4c3bea2ee9c5ed2f5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 19 Nov 2025 22:31:21 +0000 Subject: [PATCH 4/7] avoid processing markup --- src/textual/content.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/content.py b/src/textual/content.py index bb1f8f1164..bc2bb2ed52 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -276,6 +276,8 @@ def from_markup(cls, markup: str | Content, **variables: object) -> Content: raise ValueError("A literal string is require to substitute variables.") return markup markup = _strip_control_codes(markup) + if "[" not in markup and not variables: + return Content(markup) from textual.markup import to_content content = to_content(markup, template_variables=variables or None) From d6e3fc0948eebbd30c3d764fcf1a390acca59d51 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 26 Nov 2025 13:56:07 +0000 Subject: [PATCH 5/7] fold method --- CHANGELOG.md | 1 + src/textual/content.py | 48 ++++++++++++ tests/test_content.py | 170 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d972c4a08..f3e168a6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `GridLayout.max_column_width` https://github.com/Textualize/textual/pull/6228 +- Added `Content.fold` ### Changed diff --git a/src/textual/content.py b/src/textual/content.py index bc2bb2ed52..36b6fe4eca 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -966,6 +966,54 @@ def wrap( content_lines = [line.content for line in lines] return content_lines + def fold(self, width: int) -> list[Content]: + """Fold this line into a list of lines which have a cell length no greater than `width`. + + Folded lines may be 1 less than the width if it contains double width characters (which may + not be subdivided). + + Note that this method will not do any word wrappig. For that, see [wrap()][textual.content.Content.wrap]. + + Args: + width: Desired maximum width (in cells) + + Returns: + List of content instances. + """ + if not self: + return [] + text = self.plain + lines: list[Content] = [] + position = 0 + width = max(width, 2) + while text: + snip = text[position : position + width] + if not snip: + break + snip_cell_length = cell_len(snip) + if snip_cell_length < width: + # last snip + lines.append(self[position : position + width]) + break + if snip_cell_length == width: + # Cell length is exactly width + lines.append(self[position : position + width]) + text = text[len(snip) :] + position += len(snip) + continue + # TODO: Can this be more efficient? + extra_cells = snip_cell_length - width + if start_snip := extra_cells // 2: + snip_cell_length -= cell_len(snip[-start_snip:]) + snip = snip[: len(snip) - start_snip] + while snip_cell_length > width: + snip_cell_length -= cell_len(snip[-1]) + snip = snip[:-1] + lines.append(self[position : position + len(snip)]) + position += len(snip) + + return lines + def get_style_at_offset(self, offset: int) -> Style: """Get the style of a character at give offset. diff --git a/tests/test_content.py b/tests/test_content.py index 1232bc0787..57f2996cb3 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -380,3 +380,173 @@ def test_wrap() -> None: assert len(wrapped) == len(expected) for line1, line2 in zip(wrapped, expected): assert line1.is_same(line2) + + +@pytest.mark.parametrize( + "content, width, expected", + [ + ( + Content(""), + 10, + [Content("")], + ), + ( + Content("1"), + 10, + [Content("1")], + ), + ( + Content("📦"), + 10, + [Content("📦")], + ), + ( + Content("📦"), + 1, + [Content("📦")], + ), + ( + Content("Hello"), + 10, + [Content("Hello")], + ), + ( + Content("Hello"), + 5, + [Content("Hello")], + ), + ( + Content("Hello"), + 2, + [Content("He"), Content("ll"), Content("o")], + ), + ( + Content.from_markup("H[b]ell[/]o"), + 2, + [ + Content.from_markup("H[b]e"), + Content.from_markup("[b]ll[/]"), + Content("o"), + ], + ), + ( + Content.from_markup("💩H[b]ell[/]o"), + 2, + [ + Content("💩"), + Content.from_markup("H[b]e"), + Content.from_markup("[b]ll[/]"), + Content("o"), + ], + ), + ( + Content.from_markup("💩H[b]ell[/]o"), + 3, + [ + Content("💩H"), + Content.from_markup("[b]ell"), + Content.from_markup("[b]o[/]"), + ], + ), + ( + Content.from_markup("💩H[b]ell[/]💩"), + 3, + [ + Content("💩H"), + Content.from_markup("[b]ell"), + Content.from_markup("[b]o[/]💩"), + ], + ), + ( + Content.from_markup("💩💩💩"), + 1, + [ + Content("💩"), + Content("💩"), + Content("💩"), + ], + ), + ( + Content.from_markup("💩💩💩"), + 3, + [ + Content("💩"), + Content("💩"), + Content("💩"), + ], + ), + ( + Content.from_markup("💩💩💩"), + 4, + [ + Content("💩💩"), + Content("💩"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 50, + [Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999")], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 49, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦99"), + Content("9"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 48, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦9"), + Content("99"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 47, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦"), + Content("999"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 46, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888"), + Content("📦999"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 45, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888"), + Content("📦999"), + ], + ), + ( + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦888📦999"), + 44, + [ + Content("📦000📦111📦222📦333📦444📦555📦666📦777📦88"), + Content("8📦999"), + ], + ), + ], +) +def test_fold(content: Content, width: int, expected: list[Content]) -> None: + """Test content.fold method works, and correctly handles double width cells. + + Args: + content: Test content. + width: Desired width. + expected: Expectected result. + """ + result = content.fold(width) + assert isinstance(result, list) + for line, expected_line in zip(result, expected): + assert line.is_same(expected_line) From 9bf62771461a4bc92cf4fb2f1ac024ca7405bdbd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 26 Nov 2025 13:56:53 +0000 Subject: [PATCH 6/7] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e168a6a5..6ac1ca5ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `GridLayout.max_column_width` https://github.com/Textualize/textual/pull/6228 -- Added `Content.fold` +- Added `Content.fold` https://github.com/Textualize/textual/pull/6238 ### Changed From a4de2086db9557f65bb4f228a9ba1ae5db5734ab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 26 Nov 2025 13:59:33 +0000 Subject: [PATCH 7/7] changelog --- CHANGELOG.md | 2 ++ src/textual/content.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac1ca5ac5..088354cde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `GridLayout.max_column_width` https://github.com/Textualize/textual/pull/6228 - Added `Content.fold` https://github.com/Textualize/textual/pull/6238 +- Added `strip_control_codes` to Content constructors https://github.com/Textualize/textual/pull/6238 + ### Changed diff --git a/src/textual/content.py b/src/textual/content.py index 36b6fe4eca..e2a29ef704 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -106,7 +106,7 @@ def extend(self, cells: int) -> "Span": return Span(start, end + cells, style) return self - def shift(self, distance: int) -> "Span": + def _shift(self, distance: int) -> "Span": """Shift a span a given distance. Note that the start offset is clamped to 0. @@ -794,7 +794,7 @@ def get_text_at(offset: int) -> "Content": else: text = self.plain[start:stop] spans = [ - span.shift(-start) for span in self._spans if span.end > start + span._shift(-start) for span in self._spans if span.end > start ] return Content( text, self._trim_spans(text, spans), strip_control_codes=False