From 9db5b5df58931d318d2d16b15d56c03ea447cad8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Sep 2024 16:43:24 +0100 Subject: [PATCH] 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):