From 9af69ffc96624f012efccefeb3eb1fcb4d98b7fc Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Sep 2024 11:52:22 +0100 Subject: [PATCH 01/17] Some RichLog.write changes --- src/textual/widgets/_rich_log.py | 80 +++++++++++++++++++------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 404760a96b..d18580c87e 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -13,7 +13,7 @@ from rich.text import Text from ..cache import LRUCache -from ..geometry import Region, Size +from ..geometry import Region, Size, clamp from ..reactive import var from ..scroll_view import ScrollView from ..strip import Strip @@ -75,7 +75,6 @@ def __init__( self.lines: list[Strip] = [] self._line_cache: LRUCache[tuple[int, int, int, int], Strip] self._line_cache = LRUCache(1024) - self.max_width: int = 0 self.min_width = min_width """Minimum width of renderables.""" self.wrap = wrap @@ -89,14 +88,14 @@ def __init__( self.highlighter: Highlighter = ReprHighlighter() """Rich Highlighter used to highlight content when highlight is True""" - self._last_container_width: int = min_width - """Record the last width we rendered content at.""" - def notify_style_update(self) -> None: self._line_cache.clear() - def on_resize(self) -> None: - self._last_container_width = self.scrollable_content_region.width + def on_mount(self) -> None: + # Set the initial virtual size to the minimum width. + # We should ensure the virtual size never falls below the min width, + # and all rendering should be based on/restricted to the virtual size. + self.virtual_size = Size(self.min_width, len(self.lines)) def _make_renderable(self, content: RenderableType | object) -> RenderableType: """Make content renderable. @@ -134,13 +133,17 @@ def write( shrink: bool = True, scroll_end: bool | None = None, ) -> Self: - """Write text or a rich renderable. + """Write a string or a Rich renderable to the log. Args: - content: Rich renderable (or text). + content: Rich renderable (or a string). width: Width to render or `None` to use optimal width. + If a `min_width` is specified on the widget, then the width will be + expanded to be at least `min_width`. expand: Enable expand to widget width, or `False` to use `width`. + If `width` is not `None`, then `expand` will be ignored. shrink: Enable shrinking of content to fit width. + If `width` is not `None`, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. Returns: @@ -157,35 +160,43 @@ def write( if isinstance(renderable, Text) and not self.wrap: render_options = render_options.update(overflow="ignore", no_wrap=True) - render_width = measure_renderables( - console, render_options, [renderable] - ).maximum - - container_width = ( - self.scrollable_content_region.width if width is None else width - ) + if width is not None: + # Use the width specified by the caller. + # Note that we ignore `expand` and `shrink` when a width is specified. + render_width = width + else: + # Compute the width based on available information. + renderable_width = measure_renderables( + console, render_options, [renderable] + ).maximum - # Use the container_width if it's available, otherwise use the last available width. - container_width = ( - container_width if container_width else self._last_container_width - ) + render_width = clamp( + renderable_width, self.min_width, self.virtual_size.width + ) + scrollable_content_width = self.scrollable_content_region.width + if expand: + # Expand the renderable to the width of the scrollable content region. + render_width = max(renderable_width, scrollable_content_width) - if expand and render_width < container_width: - render_width = container_width - if shrink and render_width > container_width: - render_width = container_width + if shrink: + # Shrink the renderable down to fit within the scrollable content region. + render_width = min(renderable_width, scrollable_content_width) + # Ensure we don't render below the minimum width. render_width = max(render_width, self.min_width) + render_options = render_options.update_width(render_width) - segments = self.app.console.render( - renderable, render_options.update_width(render_width) - ) + # Render into possibly wrapped lines. + segments = self.app.console.render(renderable, render_options) lines = list(Segment.split_lines(segments)) + if not lines: + max_width = max(render_width, self.virtual_size.width) self.lines.append(Strip.blank(render_width)) else: - self.max_width = max( - self.max_width, + # Compute the width after wrapping + max_width = max( + self.virtual_size.width, max(sum([segment.cell_length for segment in _line]) for _line in lines), ) strips = Strip.from_lines(lines) @@ -197,7 +208,11 @@ def write( self._start_line += len(self.lines) - self.max_lines self.refresh() self.lines = self.lines[-self.max_lines :] - self.virtual_size = Size(self.max_width, len(self.lines)) + + # Update the virtual size - the width may have changed after adding + # the new line(s), and the height will definitely have changed. + self.virtual_size = Size(max_width, len(self.lines)) + if auto_scroll: self.scroll_end(animate=False) @@ -212,8 +227,7 @@ def clear(self) -> Self: self.lines.clear() self._line_cache.clear() self._start_line = 0 - self.max_width = 0 - self.virtual_size = Size(self.max_width, len(self.lines)) + self.virtual_size = Size(0, len(self.lines)) self.refresh() return self @@ -239,7 +253,7 @@ def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: if y >= len(self.lines): return Strip.blank(width, self.rich_style) - key = (y + self._start_line, scroll_x, width, self.max_width) + key = (y + self._start_line, scroll_x, width, self.virtual_size.width) if key in self._line_cache: return self._line_cache[key] From 46983ef6ad2ff9030cbd4c465d7fffed3baf36d1 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Sep 2024 13:39:39 +0100 Subject: [PATCH 02/17] Using scrollable content region width while rendering RichLog lines --- src/textual/widgets/_rich_log.py | 57 +++++++++++++++----------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index d18580c87e..4a9046b7a8 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -12,8 +12,10 @@ from rich.segment import Segment from rich.text import Text +from textual.events import Resize + from ..cache import LRUCache -from ..geometry import Region, Size, clamp +from ..geometry import Size from ..reactive import var from ..scroll_view import ScrollView from ..strip import Strip @@ -88,14 +90,17 @@ def __init__( self.highlighter: Highlighter = ReprHighlighter() """Rich Highlighter used to highlight content when highlight is True""" + self._widest_line_width = 0 + """The width of the widest line currently in the log.""" + def notify_style_update(self) -> None: self._line_cache.clear() def on_mount(self) -> None: - # Set the initial virtual size to the minimum width. - # We should ensure the virtual size never falls below the min width, - # and all rendering should be based on/restricted to the virtual size. - self.virtual_size = Size(self.min_width, len(self.lines)) + print("mounting!") + + def on_resize(self, event: Resize) -> None: + print("resize", event) def _make_renderable(self, content: RenderableType | object) -> RenderableType: """Make content renderable. @@ -170,15 +175,14 @@ def write( console, render_options, [renderable] ).maximum - render_width = clamp( - renderable_width, self.min_width, self.virtual_size.width - ) + render_width = renderable_width scrollable_content_width = self.scrollable_content_region.width + if expand: # Expand the renderable to the width of the scrollable content region. render_width = max(renderable_width, scrollable_content_width) - if shrink: + if shrink and not expand: # Shrink the renderable down to fit within the scrollable content region. render_width = min(renderable_width, scrollable_content_width) @@ -191,14 +195,9 @@ def write( lines = list(Segment.split_lines(segments)) if not lines: - max_width = max(render_width, self.virtual_size.width) + self._widest_line_width = max(render_width, self._widest_line_width) self.lines.append(Strip.blank(render_width)) else: - # Compute the width after wrapping - max_width = max( - self.virtual_size.width, - max(sum([segment.cell_length for segment in _line]) for _line in lines), - ) strips = Strip.from_lines(lines) for strip in strips: strip.adjust_cell_length(render_width) @@ -209,9 +208,17 @@ def write( self.refresh() self.lines = self.lines[-self.max_lines :] + # Compute the width after wrapping and trimming + # TODO - this is wrong because if we trim a long line, the max width + # could decrease, but we don't look at which lines were trimmed here. + self._widest_line_width = max( + self._widest_line_width, + max(sum([segment.cell_length for segment in _line]) for _line in lines), + ) + # Update the virtual size - the width may have changed after adding # the new line(s), and the height will definitely have changed. - self.virtual_size = Size(max_width, len(self.lines)) + self.virtual_size = Size(self._widest_line_width, len(self.lines)) if auto_scroll: self.scroll_end(animate=False) @@ -233,27 +240,17 @@ def clear(self) -> Self: def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset - line = self._render_line(scroll_y + y, scroll_x, self.size.width) + line = self._render_line( + scroll_y + y, scroll_x, self.scrollable_content_region.width + ) strip = line.apply_style(self.rich_style) return strip - def render_lines(self, crop: Region) -> list[Strip]: - """Render the widget in to lines. - - Args: - crop: Region within visible area to. - - Returns: - A list of list of segments. - """ - lines = self._styles_cache.render_widget(self, crop) - return lines - def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: if y >= len(self.lines): return Strip.blank(width, self.rich_style) - key = (y + self._start_line, scroll_x, width, self.virtual_size.width) + key = (y + self._start_line, scroll_x, width, self._widest_line_width) if key in self._line_cache: return self._line_cache[key] From 9db5b5df58931d318d2d16b15d56c03ea447cad8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Sep 2024 16:43:24 +0100 Subject: [PATCH 03/17] Rich log deferring renders --- src/textual/widgets/_rich_log.py | 53 +++++++-- .../test_richlog_deferred_render_expand.svg | 78 ++++++++++++ ...test_richlog_deferred_render_no_expand.svg | 78 ++++++++++++ .../test_snapshots/test_richlog_markup.svg | 79 +++++++++++++ .../test_snapshots/test_richlog_min_width.svg | 79 +++++++++++++ .../test_snapshots/test_richlog_width.svg | 111 +++++++++--------- .../snapshot_apps/richlog_width.py | 25 +++- tests/snapshot_tests/test_snapshots.py | 88 ++++++++++---- 8 files changed, 501 insertions(+), 90 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_expand.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_no_expand.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_markup.svg create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_min_width.svg diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 4a9046b7a8..ccf2c947f6 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, cast +from collections import deque +from typing import TYPE_CHECKING, NamedTuple, Optional, cast from rich.console import RenderableType from rich.highlighter import Highlighter, ReprHighlighter @@ -24,8 +25,23 @@ from typing_extensions import Self +class DeferredRender(NamedTuple): + """A renderable which is awaiting rendering. + This may happen if a `write` occurs before the width is known. + + The arguments are the same as for `RichLog.write`, as this just + represents a deferred call to that method. + """ + + content: RenderableType | object + width: int | None = None + expand: bool = False + shrink: bool = True + scroll_end: bool | None = None + + class RichLog(ScrollView, can_focus=True): - """A widget for logging text.""" + """A widget for logging Rich renderables and text.""" DEFAULT_CSS = """ RichLog{ @@ -56,11 +72,13 @@ def __init__( classes: str | None = None, disabled: bool = False, ) -> None: - """Create a RichLog widget. + """Create a `RichLog` widget. Args: max_lines: Maximum number of lines in the log or `None` for no maximum. - min_width: Minimum width of renderables. + min_width: Minimum width of the renderable area. Ensures that even if the + width of the `RichLog` is constrained, content will always be written at + at least this width. wrap: Enable word wrapping (default is off). highlight: Automatically highlight content. markup: Apply Rich console markup. @@ -77,6 +95,8 @@ def __init__( self.lines: list[Strip] = [] self._line_cache: LRUCache[tuple[int, int, int, int], Strip] self._line_cache = LRUCache(1024) + self._deferred_renders: deque[DeferredRender] = deque() + """Queue of deferred renderables to be rendered.""" self.min_width = min_width """Minimum width of renderables.""" self.wrap = wrap @@ -93,14 +113,19 @@ def __init__( self._widest_line_width = 0 """The width of the widest line currently in the log.""" + self._size_known = False + def notify_style_update(self) -> None: self._line_cache.clear() - def on_mount(self) -> None: - print("mounting!") - def on_resize(self, event: Resize) -> None: - print("resize", event) + if event.size.width and not self._size_known: + # This size is known for the first time. + self._size_known = True + deferred_renders = self._deferred_renders + while deferred_renders: + deferred_render = deferred_renders.popleft() + self.write(*deferred_render) def _make_renderable(self, content: RenderableType | object) -> RenderableType: """Make content renderable. @@ -154,14 +179,19 @@ def write( Returns: The `RichLog` instance. """ + if not self._size_known: + # We don't know the size yet, so we'll need to render this later. + self._deferred_renders.append( + DeferredRender(content, width, expand, shrink, scroll_end) + ) + return self + renderable = self._make_renderable(content) auto_scroll = self.auto_scroll if scroll_end is None else scroll_end console = self.app.console render_options = console.options - renderable = self._make_renderable(content) - if isinstance(renderable, Text) and not self.wrap: render_options = render_options.update(overflow="ignore", no_wrap=True) @@ -188,9 +218,10 @@ def write( # Ensure we don't render below the minimum width. render_width = max(render_width, self.min_width) + render_options = render_options.update_width(render_width) - # Render into possibly wrapped lines. + # Render into (possibly) wrapped lines. segments = self.app.console.render(renderable, render_options) lines = list(Segment.split_lines(segments)) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_expand.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_expand.svg new file mode 100644 index 0000000000..1bab01e664 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_expand.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogExpand + + + + + + + + + +         0123456789 + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_no_expand.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_no_expand.svg new file mode 100644 index 0000000000..5bd46a742f --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_deferred_render_no_expand.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogNoExpand + + + + + + + + + + 0123456789 + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_markup.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_markup.svg new file mode 100644 index 0000000000..bdcefaa652 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_markup.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogWidth + + + + + + + + + + black text on red, underlined +normal text, no markup                   + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_min_width.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_min_width.svg new file mode 100644 index 0000000000..4d4259d1fe --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_min_width.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogMinWidth20 + + + + + + + + + +           01234567 + + + + + + + + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_width.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_width.svg index c64a707297..0a780c89b8 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_width.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_width.svg @@ -19,131 +19,132 @@ font-weight: 700; } - .terminal-31396663-matrix { + .terminal-978782605-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-31396663-title { + .terminal-978782605-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-31396663-r1 { fill: #e1e1e1 } -.terminal-31396663-r2 { fill: #c5c8c6 } + .terminal-978782605-r1 { fill: #1a1a1a } +.terminal-978782605-r2 { fill: #e1e1e1 } +.terminal-978782605-r3 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RichLogWidth + RichLogWidth - - - -               hello1 -              world2 -              hello3 -              world4 - - - - - - - - - - - - - - - - - - - + + + +                                                             written in compose +                                                                        hello1 +                                                                        world2 +                                                                        hello3 +                                                                        world4 + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/snapshot_apps/richlog_width.py b/tests/snapshot_tests/snapshot_apps/richlog_width.py index 97ff437d2f..f84551a6ca 100644 --- a/tests/snapshot_tests/snapshot_apps/richlog_width.py +++ b/tests/snapshot_tests/snapshot_apps/richlog_width.py @@ -1,11 +1,32 @@ -from rich.text import Text from textual.app import App, ComposeResult +from textual.events import Resize from textual.widgets import RichLog +from rich.text import Text + class RichLogWidth(App[None]): def compose(self) -> ComposeResult: - yield RichLog(min_width=20) + rich_log = RichLog(min_width=20) + rich_log.display = False + rich_log.write( + Text("written in compose", style="black on white", justify="right"), + expand=True, + ) + yield rich_log + + def key_p(self, event: Resize) -> None: + rich_log: RichLog = self.query_one(RichLog) + rich_log.display = True + rich_log.write(Text("hello1", style="on red", justify="right"), expand=True) + rich_log.visible = False + rich_log.write(Text("world2", style="on green", justify="right"), expand=True) + rich_log.visible = True + rich_log.write(Text("hello3", style="on blue", justify="right"), expand=True) + rich_log.display = False + rich_log.write(Text("world4", style="on yellow", justify="right"), expand=True) + rich_log.display = True + app = RichLogWidth() if __name__ == "__main__": diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 864f80e3fe..d55d784b5e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,6 +1,7 @@ from pathlib import Path import pytest +from rich.text import Text from tests.snapshot_tests.language_snippets import SNIPPETS from textual.app import App, ComposeResult @@ -204,10 +205,6 @@ def test_list_view(snap_compare): ) -def test_richlog_max_lines(snap_compare): - assert snap_compare("snapshot_apps/richlog_max_lines.py", press=[*"abcde"]) - - def test_log_write_lines(snap_compare): assert snap_compare("snapshot_apps/log_write_lines.py") @@ -568,29 +565,76 @@ def test_table_markup(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "table_markup.py") +def test_richlog_max_lines(snap_compare): + assert snap_compare("snapshot_apps/richlog_max_lines.py", press=[*"abcde"]) + + def test_richlog_scroll(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_scroll.py") def test_richlog_width(snap_compare): - """Check that min_width applies in RichLog and that we can write - to the RichLog when it's not visible, and it still renders as expected - when made visible again.""" - - async def setup(pilot): - from rich.text import Text - - rich_log: RichLog = pilot.app.query_one(RichLog) - rich_log.write(Text("hello1", style="on red", justify="right"), expand=True) - rich_log.visible = False - rich_log.write(Text("world2", style="on green", justify="right"), expand=True) - rich_log.visible = True - rich_log.write(Text("hello3", style="on blue", justify="right"), expand=True) - rich_log.display = False - rich_log.write(Text("world4", style="on yellow", justify="right"), expand=True) - rich_log.display = True - - assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_width.py", run_before=setup) + """Check that the width of RichLog is respected, even when it's not visible.""" + assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_width.py", press=["p"]) + + +def test_richlog_min_width(snap_compare): + """The available space of this RichLog is less than the minimum width, so written + content should be rendered at `min_width`. This snapshot should show the renderable + clipping at the right edge, as there's not enough space to satisfy the minimum width.""" + + class RichLogMinWidth20(App[None]): + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=20) + text = Text("0123456789", style="on red", justify="right") + rich_log.write(text) + yield rich_log + + assert snap_compare(RichLogMinWidth20(), terminal_size=(20, 6)) + + +def test_richlog_deferred_render_no_expand(snap_compare): + """Check we can write to a RichLog before its size is known i.e. in `compose`.""" + + class RichLogNoExpand(App[None]): + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=10) + text = Text("0123456789", style="on red", justify="right") + # Perform the write in compose - it'll be deferred until the size is known + rich_log.write(text) + yield rich_log + + assert snap_compare(RichLogNoExpand(), terminal_size=(20, 6)) + + +def test_richlog_deferred_render_expand(snap_compare): + """Check we can write to a RichLog before its size is known i.e. in `compose`. + + The renderable should expand to fill full the width of the RichLog. + """ + + class RichLogExpand(App[None]): + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=10) + text = Text("0123456789", style="on red", justify="right") + # Perform the write in compose - it'll be deferred until the size is known + rich_log.write(text, expand=True) + yield rich_log + + assert snap_compare(RichLogExpand(), terminal_size=(20, 6)) + + +def test_richlog_markup(snap_compare): + """Check that Rich markup works in RichLog when markup=True.""" + + class RichLogWidth(App[None]): + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=10, markup=True) + rich_log.write("[black on red u]black text on red, underlined") + rich_log.write("normal text, no markup") + yield rich_log + + assert snap_compare(RichLogWidth(), terminal_size=(42, 6)) def test_tabs_invalidate(snap_compare): From 5ce00860524282439652a0f2c5e1a99a36f8fa55 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 09:53:31 +0100 Subject: [PATCH 04/17] Simplification in RichLog --- src/textual/widgets/_rich_log.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index ccf2c947f6..f13016cbaf 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -173,7 +173,7 @@ def write( expand: Enable expand to widget width, or `False` to use `width`. If `width` is not `None`, then `expand` will be ignored. shrink: Enable shrinking of content to fit width. - If `width` is not `None`, then `shrink` will be ignored. + If `width` is not `None`, or `expand` is `True`, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. Returns: @@ -197,7 +197,7 @@ def write( if width is not None: # Use the width specified by the caller. - # Note that we ignore `expand` and `shrink` when a width is specified. + # We ignore `expand` and `shrink` when a width is specified. render_width = width else: # Compute the width based on available information. @@ -211,8 +211,7 @@ def write( if expand: # Expand the renderable to the width of the scrollable content region. render_width = max(renderable_width, scrollable_content_width) - - if shrink and not expand: + elif shrink: # Shrink the renderable down to fit within the scrollable content region. render_width = min(renderable_width, scrollable_content_width) From 6081380194998a903f2dbee68d0333e816b87be2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 10:49:21 +0100 Subject: [PATCH 05/17] Add snapshot test for RichLog.shrink --- .../test_snapshots/test_richlog_shrink.svg | 79 +++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 15 ++++ 2 files changed, 94 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_shrink.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_shrink.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_shrink.svg new file mode 100644 index 0000000000..7f00a1e742 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_shrink.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogShrink + + + + + + + + + + ╭────────────────╮ +│ lorem ipsum    │ +│ dolor sit amet │ +│ lorem ipsum    │ +│ dolor sit amet │ +╰────────────────╯ + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index d55d784b5e..f155687dd4 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,6 +1,8 @@ from pathlib import Path import pytest +from rich.panel import Panel +from rich.table import Table from rich.text import Text from tests.snapshot_tests.language_snippets import SNIPPETS @@ -637,6 +639,19 @@ def compose(self) -> ComposeResult: assert snap_compare(RichLogWidth(), terminal_size=(42, 6)) +def test_richlog_shrink(snap_compare): + class RichLogShrink(App[None]): + CSS = "RichLog { width: 20; background: red;}" + + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=4) + panel = Panel("lorem ipsum dolor sit amet lorem ipsum dolor sit amet") + rich_log.write(panel) + yield rich_log + + assert snap_compare(RichLogShrink(), terminal_size=(24, 6)) + + def test_tabs_invalidate(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "tabs_invalidate.py", From d9f45107c1966748332290c27904507c9b867b9b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 13:18:07 +0100 Subject: [PATCH 06/17] Respect width --- src/textual/widgets/_rich_log.py | 6 ++++-- tests/snapshot_tests/test_snapshots.py | 25 ++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index f13016cbaf..6889fd2b58 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -198,6 +198,7 @@ def write( if width is not None: # Use the width specified by the caller. # We ignore `expand` and `shrink` when a width is specified. + # This also overrides `min_width` set on the RichLog. render_width = width else: # Compute the width based on available information. @@ -215,8 +216,8 @@ def write( # Shrink the renderable down to fit within the scrollable content region. render_width = min(renderable_width, scrollable_content_width) - # Ensure we don't render below the minimum width. - render_width = max(render_width, self.min_width) + # The user has not supplied a width, so make sure min_width is respected. + render_width = max(render_width, self.min_width) render_options = render_options.update_width(render_width) @@ -264,6 +265,7 @@ def clear(self) -> Self: self.lines.clear() self._line_cache.clear() self._start_line = 0 + self._widest_line_width = 0 self.virtual_size = Size(0, len(self.lines)) self.refresh() return self diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f155687dd4..c332f826a5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -572,6 +572,7 @@ def test_richlog_max_lines(snap_compare): def test_richlog_scroll(snap_compare): + """Ensure `RichLog.auto_scroll` causes the log to scroll to the end when new content is written.""" assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_scroll.py") @@ -627,7 +628,7 @@ def compose(self) -> ComposeResult: def test_richlog_markup(snap_compare): - """Check that Rich markup works in RichLog when markup=True.""" + """Check that Rich markup is rendered in RichLog when markup=True.""" class RichLogWidth(App[None]): def compose(self) -> ComposeResult: @@ -640,6 +641,8 @@ def compose(self) -> ComposeResult: def test_richlog_shrink(snap_compare): + """Ensure that when shrink=True, the renderable shrinks to fit the width of the RichLog.""" + class RichLogShrink(App[None]): CSS = "RichLog { width: 20; background: red;}" @@ -652,6 +655,26 @@ def compose(self) -> ComposeResult: assert snap_compare(RichLogShrink(), terminal_size=(24, 6)) +def test_richlog_write_at_specific_width(snap_compare): + """Ensure we can write renderables at a specific width. + `min_width` should be respected, but `width` should override. + """ + + class RichLogWriteAtSpecificWidth(App[None]): + CSS = "RichLog { width: 1fr; }" + + def compose(self) -> ComposeResult: + rich_log = RichLog(min_width=50) + panel = Panel("foo", style="black on red") + rich_log.write(panel, width=20) + rich_log.write(panel, width=40) + rich_log.write(panel, width=80) + rich_log.write(panel) + yield rich_log + + assert snap_compare(RichLogWriteAtSpecificWidth()) + + def test_tabs_invalidate(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "tabs_invalidate.py", From f63fbfb999094172b0754f7b1366dca31bccafcf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 13:26:50 +0100 Subject: [PATCH 07/17] Test to ensure RichLog.write width param is respected --- .../test_richlog_write_at_specific_width.svg | 153 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 24 ++- 2 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_write_at_specific_width.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_write_at_specific_width.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_write_at_specific_width.svg new file mode 100644 index 0000000000..9b64c84344 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_write_at_specific_width.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + RichLogWriteAtSpecificWidth + + + + + + + + + + ╭──────────────────╮ +│ width=20         │ +╰──────────────────╯ +╭──────────────────────────────────────╮ +│ width=40                             │ +╰──────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────╮ +│ width=60                                                 │ +╰──────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────────── +│ width=120                                                                    +╰───────────────────────────────────────────────────────────────────────────── +╭────────────────────────────────────────────────╮ +│ width=None (fallback to min_width)             │ +╰────────────────────────────────────────────────╯ + +this label is width 50 (same as min_width) + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c332f826a5..dbf8fd83a9 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -658,19 +658,31 @@ def compose(self) -> ComposeResult: def test_richlog_write_at_specific_width(snap_compare): """Ensure we can write renderables at a specific width. `min_width` should be respected, but `width` should override. + + The green label at the bottom should be equal in width to the bottom + renderable (equal to min_width). """ class RichLogWriteAtSpecificWidth(App[None]): - CSS = "RichLog { width: 1fr; }" + CSS = """ + RichLog { width: 1fr; height: auto; } + #width-marker { background: green; width: 50; } + """ def compose(self) -> ComposeResult: rich_log = RichLog(min_width=50) - panel = Panel("foo", style="black on red") - rich_log.write(panel, width=20) - rich_log.write(panel, width=40) - rich_log.write(panel, width=80) - rich_log.write(panel) + rich_log.write(Panel("width=20", style="black on red"), width=20) + rich_log.write(Panel("width=40", style="black on red"), width=40) + rich_log.write(Panel("width=60", style="black on red"), width=60) + rich_log.write(Panel("width=120", style="black on red"), width=120) + rich_log.write( + Panel("width=None (fallback to min_width)", style="black on red") + ) yield rich_log + width_marker = Label( + f"this label is width 50 (same as min_width)", id="width-marker" + ) + yield width_marker assert snap_compare(RichLogWriteAtSpecificWidth()) From 68d507a3d789bca16b3167a0c214ba661690dfe3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 13:35:38 +0100 Subject: [PATCH 08/17] Fix case of shrink=True and expand=True --- src/textual/widgets/_rich_log.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 6889fd2b58..7d2017e606 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -34,10 +34,15 @@ class DeferredRender(NamedTuple): """ content: RenderableType | object + """The content to render.""" width: int | None = None + """The width to render or `None` to use optimal width.""" expand: bool = False + """Enable expand to widget width, or `False` to use `width`.""" shrink: bool = True + """Enable shrinking of content to fit width.""" scroll_end: bool | None = None + """Enable automatic scroll to end, or `None` to use `self.auto_scroll`.""" class RichLog(ScrollView, can_focus=True): @@ -209,10 +214,11 @@ def write( render_width = renderable_width scrollable_content_width = self.scrollable_content_region.width - if expand: + if expand and renderable_width < scrollable_content_width: # Expand the renderable to the width of the scrollable content region. render_width = max(renderable_width, scrollable_content_width) - elif shrink: + + if shrink and renderable_width > scrollable_content_width: # Shrink the renderable down to fit within the scrollable content region. render_width = min(renderable_width, scrollable_content_width) From 1d27145e8eb96e0d43cd952fa3ee65de14a61c85 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 13:44:02 +0100 Subject: [PATCH 09/17] Fix case of shrink=True and expand=True --- src/textual/widgets/_rich_log.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 7d2017e606..6dea6b0222 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -175,9 +175,9 @@ def write( width: Width to render or `None` to use optimal width. If a `min_width` is specified on the widget, then the width will be expanded to be at least `min_width`. - expand: Enable expand to widget width, or `False` to use `width`. + expand: Permit expanding of content to the width of the RichLog. If `width` is not `None`, then `expand` will be ignored. - shrink: Enable shrinking of content to fit width. + shrink: Permit shrinking of content to fit within the RichLog. If `width` is not `None`, or `expand` is `True`, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. From 40dd9eb6db5f726297a4a0f0976fff70d2cc4350 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 14:06:23 +0100 Subject: [PATCH 10/17] Rework docstring --- src/textual/widgets/_rich_log.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 6dea6b0222..ee5764fe90 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -172,13 +172,12 @@ def write( Args: content: Rich renderable (or a string). - width: Width to render or `None` to use optimal width. - If a `min_width` is specified on the widget, then the width will be - expanded to be at least `min_width`. + width: Width to render, or `None` to use `RichLog.min_width`. + If specified, `expand` and `shrink` will be ignored. expand: Permit expanding of content to the width of the RichLog. - If `width` is not `None`, then `expand` will be ignored. + If `width` is specified, then `expand` will be ignored. shrink: Permit shrinking of content to fit within the RichLog. - If `width` is not `None`, or `expand` is `True`, then `shrink` will be ignored. + If `width` is specified, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. Returns: From bcbcbb5e42db6aaf63cf77ab8b4a8617d879feec Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 14:10:19 +0100 Subject: [PATCH 11/17] Rework docstring --- src/textual/widgets/_rich_log.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index ee5764fe90..467dc65fcf 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -168,7 +168,12 @@ def write( shrink: bool = True, scroll_end: bool | None = None, ) -> Self: - """Write a string or a Rich renderable to the log. + """Write a string or a Rich renderable to the bottom of the log. + + Notes: + The rendering of content will be deferred until the size of the `RichLog` is known. + This means if you call `write` in `compose` or `on_mount`, the content will not be + rendered immediately. Args: content: Rich renderable (or a string). @@ -185,6 +190,7 @@ def write( """ if not self._size_known: # We don't know the size yet, so we'll need to render this later. + # We defer ALL writes until the size is known, to ensure ordering is preserved. self._deferred_renders.append( DeferredRender(content, width, expand, shrink, scroll_end) ) From fd7081a62bf9f07bba04eadb1354151257c1ea1d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 14:13:28 +0100 Subject: [PATCH 12/17] Docstrings, and clear deferred renders queue on clear() call in RichLog --- src/textual/widgets/_rich_log.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 467dc65fcf..8acc1b457f 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -98,6 +98,7 @@ def __init__( """Maximum number of lines in the log or `None` for no maximum.""" self._start_line: int = 0 self.lines: list[Strip] = [] + """The lines currently visible in the log.""" self._line_cache: LRUCache[tuple[int, int, int, int], Strip] self._line_cache = LRUCache(1024) self._deferred_renders: deque[DeferredRender] = deque() @@ -119,6 +120,8 @@ def __init__( """The width of the widest line currently in the log.""" self._size_known = False + """Flag which is set to True when the size of the RichLog is known, + indicating we can proceed with rendering deferred writes.""" def notify_style_update(self) -> None: self._line_cache.clear() @@ -277,6 +280,7 @@ def clear(self) -> Self: self._line_cache.clear() self._start_line = 0 self._widest_line_width = 0 + self._deferred_renders.clear() self.virtual_size = Size(0, len(self.lines)) self.refresh() return self From 3457db4eb2481887807431bf7b4ba0b11e233ee8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 14:33:44 +0100 Subject: [PATCH 13/17] Testing rich log highlighter, docstring improvements --- src/textual/widgets/_rich_log.py | 8 ++- .../test_snapshots/test_richlog_highlight.svg | 71 +++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 22 ++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_highlight.svg diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 8acc1b457f..32d055d8fb 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -85,7 +85,9 @@ def __init__( width of the `RichLog` is constrained, content will always be written at at least this width. wrap: Enable word wrapping (default is off). - highlight: Automatically highlight content. + highlight: Automatically highlight content. By default, the `ReprHighlighter` is used. + To customize highlighting, set `highlight=True` and then set the `highlighter` + attribute to an instance of `Highlighter`. markup: Apply Rich console markup. auto_scroll: Enable automatic scrolling to end. name: The name of the text log. @@ -182,9 +184,9 @@ def write( content: Rich renderable (or a string). width: Width to render, or `None` to use `RichLog.min_width`. If specified, `expand` and `shrink` will be ignored. - expand: Permit expanding of content to the width of the RichLog. + expand: Permit expanding of content to the width of the content region of the RichLog. If `width` is specified, then `expand` will be ignored. - shrink: Permit shrinking of content to fit within the RichLog. + shrink: Permit shrinking of content to fit within the content region of the RichLog. If `width` is specified, then `shrink` will be ignored. scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_highlight.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_highlight.svg new file mode 100644 index 0000000000..555ae3a224 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_richlog_highlight.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + RichLogHighlight + + + + + + + + + + Foo('bar'x=1y=[123]) + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index dbf8fd83a9..5c41467c9f 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -687,6 +687,28 @@ def compose(self) -> ComposeResult: assert snap_compare(RichLogWriteAtSpecificWidth()) +def test_richlog_highlight(snap_compare): + """Check that RichLog.highlight correctly highlights with the ReprHighlighter. + + Also ensures that interaction between CSS and highlighting is as expected - + non-highlighted text should have the CSS styles applied, but highlighted text + should ignore the CSS (and use the highlights returned from the highlighter). + """ + + class RichLogHighlight(App[None]): + # Add some CSS to check interaction with highlighting. + CSS = """ + RichLog { color: red; background: dodgerblue 40%; } + """ + + def compose(self) -> ComposeResult: + rich_log = RichLog(highlight=True) + rich_log.write("Foo('bar', x=1, y=[1, 2, 3])") + yield rich_log + + assert snap_compare(RichLogHighlight(), terminal_size=(30, 3)) + + def test_tabs_invalidate(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "tabs_invalidate.py", From 59b9891ccbd3b4900c3ba1b0614488c484c7944f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 15:05:02 +0100 Subject: [PATCH 14/17] Simplify docstring --- src/textual/widgets/_rich_log.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index 32d055d8fb..e33a164768 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -81,9 +81,7 @@ def __init__( Args: max_lines: Maximum number of lines in the log or `None` for no maximum. - min_width: Minimum width of the renderable area. Ensures that even if the - width of the `RichLog` is constrained, content will always be written at - at least this width. + min_width: Width to use for calls to `write` with no specified `width`. wrap: Enable word wrapping (default is off). highlight: Automatically highlight content. By default, the `ReprHighlighter` is used. To customize highlighting, set `highlight=True` and then set the `highlighter` From 41ec20d45131661d427dc05658b7f365ff1f48bf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 15:20:24 +0100 Subject: [PATCH 15/17] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4be0225d9..d6bff10f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Input validation of floats no longer accepts NaN (not a number). https://github.com/Textualize/textual/pull/4784 - Fixed issues with screenshots by simplifying segments only for snapshot tests https://github.com/Textualize/textual/issues/4929 +- Fixed `RichLog.write` not respecting `width` parameter https://github.com/Textualize/textual/pull/4978 +- Fixed `RichLog` writing at wrong width when `write` occurs before width is known (e.g. in `compose` or `on_mount`) https://github.com/Textualize/textual/pull/4978 +- Fixed `RichLog.write` incorrectly shrinking width to `RichLog.min_width` when `shrink=True` (now shrinks to fit content area instead) https://github.com/Textualize/textual/pull/4978 ## [0.79.1] - 2024-08-31 From e92ca52443228cae543cc3b8554bcc2ade506b81 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Sep 2024 15:24:36 +0100 Subject: [PATCH 16/17] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bff10f09..eeb758ff8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Input validation for integers no longer accepts scientific notation like '1.5e2'; must be castable to int. https://github.com/Textualize/textual/pull/4784 +- Some fixes in `RichLog` result in slightly different semantics, see docstrings for details https://github.com/Textualize/textual/pull/4978 ### Fixed From a2fc4b1d2e6083cbedff653d2a6618b8c4d7997d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 11 Sep 2024 14:22:50 +0100 Subject: [PATCH 17/17] Copy on write to Rich log if deferring and content is Text instance --- src/textual/widgets/_rich_log.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_rich_log.py b/src/textual/widgets/_rich_log.py index e33a164768..abe77a2237 100644 --- a/src/textual/widgets/_rich_log.py +++ b/src/textual/widgets/_rich_log.py @@ -194,6 +194,8 @@ def write( if not self._size_known: # We don't know the size yet, so we'll need to render this later. # We defer ALL writes until the size is known, to ensure ordering is preserved. + if isinstance(content, Text): + content = content.copy() self._deferred_renders.append( DeferredRender(content, width, expand, shrink, scroll_end) )