diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d972c4a08..088354cde7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ 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` 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/_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..e2a29ef704 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) + + 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 @@ -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 @@ -248,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) @@ -321,6 +351,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 +359,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) + return EMPTY_CONTENT + 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 +399,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 +431,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 +446,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 +460,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 +473,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 +753,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 +773,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 +782,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 +807,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( @@ -755,7 +819,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 @@ -801,8 +869,9 @@ 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]) + 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. @@ -836,12 +905,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 @@ -889,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. @@ -935,13 +1060,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 +1090,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 +1116,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 +1133,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 +1160,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 @@ -1087,7 +1219,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 @@ -1114,6 +1246,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 +1280,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( @@ -1493,7 +1629,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( @@ -1531,7 +1667,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: 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 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)