Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

RichLog fixes #4978

Merged
merged 18 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 97 additions & 56 deletions src/textual/widgets/_rich_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,8 +13,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
from ..geometry import Size
from ..reactive import var
from ..scroll_view import ScrollView
from ..strip import Strip
Expand All @@ -22,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{
Expand Down Expand Up @@ -54,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.
Expand All @@ -75,7 +95,8 @@ 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._deferred_renders: deque[DeferredRender] = deque()
"""Queue of deferred renderables to be rendered."""
self.min_width = min_width
"""Minimum width of renderables."""
self.wrap = wrap
Expand All @@ -89,14 +110,22 @@ 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."""
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_resize(self) -> None:
self._last_container_width = self.scrollable_content_region.width
def on_resize(self, event: Resize) -> None:
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.
Expand Down Expand Up @@ -134,60 +163,71 @@ 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`, or `expand` is `True`, then `shrink` will be ignored.
scroll_end: Enable automatic scroll to end, or `None` to use `self.auto_scroll`.

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)

render_width = measure_renderables(
console, render_options, [renderable]
).maximum

container_width = (
self.scrollable_content_region.width if width is None else width
)

# 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
)

if expand and render_width < container_width:
render_width = container_width
if shrink and render_width > container_width:
render_width = container_width

if width is not None:
# Use the width specified by the caller.
# 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

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)
elif 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)

segments = self.app.console.render(
renderable, render_options.update_width(render_width)
)
render_options = 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:
self._widest_line_width = max(render_width, self._widest_line_width)
self.lines.append(Strip.blank(render_width))
else:
self.max_width = max(
self.max_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)
Expand All @@ -197,7 +237,19 @@ 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))

# 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(self._widest_line_width, len(self.lines))

if auto_scroll:
self.scroll_end(animate=False)

Expand All @@ -212,34 +264,23 @@ 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

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.max_width)
key = (y + self._start_line, scroll_x, width, self._widest_line_width)
if key in self._line_cache:
return self._line_cache[key]

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading