From aa159dda1446e47b2a61ca4c349691424f8bf02c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 25 Jan 2025 14:58:30 +0000 Subject: [PATCH] Visual protocol --- src/textual/_border.py | 3 -- src/textual/content.py | 55 +++++++++++++---------------- src/textual/css/styles.py | 34 ++++++++++++++++-- src/textual/css/tokenizer.py | 2 ++ src/textual/strip.py | 21 ++++++++++++ src/textual/visual.py | 63 ++++++++++++++++++++++++---------- src/textual/widget.py | 5 +-- src/textual/widgets/_digits.py | 2 +- src/textual/widgets/_log.py | 4 +-- 9 files changed, 129 insertions(+), 60 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index cde7862e33..70d04aa95d 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -271,9 +271,6 @@ def get_box( Returns: A tuple of 3 Segment triplets. """ - assert isinstance(inner_style, Style) - assert isinstance(outer_style, Style) - assert isinstance(style, Style) _Segment = Segment ( (top1, top2, top3), diff --git a/src/textual/content.py b/src/textual/content.py index 73a8982a07..7005b0cb76 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -19,6 +19,7 @@ from rich.cells import set_cell_size from rich.console import OverflowMethod from rich.segment import Segment, Segments +from rich.style import Style as RichStyle from rich.terminal_theme import TerminalTheme from rich.text import Text from typing_extensions import Final, TypeAlias @@ -27,6 +28,7 @@ from textual._context import active_app from textual._loop import loop_last from textual.color import Color +from textual.css.styles import RulesMap from textual.css.types import TextAlign from textual.selection import Selection from textual.strip import Strip @@ -34,7 +36,7 @@ from textual.visual import Visual if TYPE_CHECKING: - from textual.widget import Widget + pass ContentType: TypeAlias = Union["Content", str] @@ -275,7 +277,11 @@ def __lt__(self, other: object) -> bool: return self.plain < other.plain return NotImplemented - def get_optimal_width(self, widget: Widget, container_width: int) -> int: + def get_optimal_width( + self, + rules: RulesMap, + container_width: int, + ) -> int: """Get optimal width of the visual to display its content. Part of the Textual Visual protocol. Args: @@ -289,7 +295,7 @@ def get_optimal_width(self, widget: Widget, container_width: int) -> int: lines = self.without_spans.split("\n") return max(line.cell_length for line in lines) - def get_height(self, widget: Widget, width: int) -> int: + def get_height(self, rules: RulesMap, width: int) -> int: """Get the height of the visual if rendered with the given width. Part of the Textual Visual protocol. Args: @@ -365,37 +371,30 @@ def get_span(y: int) -> tuple[int, int] | None: def render_strips( self, - widget: Widget, + rules: RulesMap, width: int, height: int | None, style: Style, + selection: Selection | None = None, + selection_style: Style | None = None, ) -> list[Strip]: if not width: return [] - selection = widget.selection - if selection is not None: - selection_style = Style.from_rich_style( - widget.screen.get_component_rich_style("screen--selection") - ) - - else: - selection_style = None - lines = self._wrap_and_format( width, - align=widget.styles.text_align, + align=rules.get("text_align", "left"), overflow="fold", no_wrap=False, tab_size=8, - selection=widget.selection, + selection=selection, selection_style=selection_style, ) if height is not None: lines = lines[:height] - strip_lines = [line.to_strip(widget, style) for line in lines] + strip_lines = [Strip(*line.to_strip(style)) for line in lines] return strip_lines def __len__(self) -> int: @@ -1128,7 +1127,7 @@ def __init__( def plain(self) -> str: return self.content.plain - def to_strip(self, widget: Widget, style: Style) -> Strip: + def to_strip(self, style: Style) -> tuple[list[Segment], int]: _Segment = Segment align = self.align width = self.width @@ -1137,8 +1136,6 @@ def to_strip(self, widget: Widget, style: Style) -> Strip: x = self.x y = self.y - parse_style = widget.app.stylesheet.parse_style - if align in ("start", "left") or (align == "justify" and self.line_end): pass @@ -1166,9 +1163,7 @@ def to_strip(self, widget: Widget, style: Style) -> Strip: add_segment = segments.append x = self.x for index, word in enumerate(words): - for text, text_style in word.render( - style, end="", parse_style=parse_style - ): + for text, text_style in word.render(style, end=""): add_segment( _Segment( text, (style + text_style).rich_style_with_offset(x, y) @@ -1178,8 +1173,7 @@ def to_strip(self, widget: Widget, style: Style) -> Strip: if index < len(spaces) and (pad := spaces[index]): add_segment(_Segment(" " * pad, (style + text_style).rich_style)) - strip = Strip(self._apply_link_style(widget, segments), width) - return strip + return segments, width segments = ( [Segment(" " * pad_left, style.background_style.rich_style)] @@ -1187,7 +1181,7 @@ def to_strip(self, widget: Widget, style: Style) -> Strip: else [] ) add_segment = segments.append - for text, text_style in content.render(style, end="", parse_style=parse_style): + for text, text_style in content.render(style, end=""): add_segment( _Segment(text, (style + text_style).rich_style_with_offset(x, y)) ) @@ -1197,16 +1191,13 @@ def to_strip(self, widget: Widget, style: Style) -> Strip: segments.append( _Segment(" " * pad_right, style.background_style.rich_style) ) - strip = Strip( - self._apply_link_style(widget, segments), - content.cell_length + pad_left + pad_right, - ) - return strip + + return (segments, content.cell_length + pad_left + pad_right) def _apply_link_style( - self, widget: Widget, segments: list[Segment] + self, link_style: RichStyle, segments: list[Segment] ) -> list[Segment]: - link_style = widget.link_style + _Segment = Segment segments = [ _Segment( diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 53a6456307..d3045b1ba6 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,9 +1,10 @@ from __future__ import annotations +from collections import abc from dataclasses import dataclass, field from functools import partial from operator import attrgetter -from typing import TYPE_CHECKING, Any, Callable, Iterable, Literal, cast +from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Literal, cast import rich.repr from rich.style import Style @@ -1245,7 +1246,7 @@ def css(self) -> str: @rich.repr.auto -class RenderStyles(StylesBase): +class RenderStyles(StylesBase, abc.Mapping): """Presents a combined view of two Styles object: a base Styles and inline Styles.""" def __init__(self, node: DOMNode, base: Styles, inline_styles: Styles) -> None: @@ -1265,6 +1266,35 @@ def __eq__(self, other: object) -> bool: ) return NotImplemented + def __getitem__(self, key: str) -> object: + if key not in RULE_NAMES_SET: + raise KeyError(key) + return getattr(self, key) + + def get(self, key: str, default: object | None = None) -> object: + return getattr(self, key) if key in RULE_NAMES_SET else default + + def __len__(self) -> int: + return len(RULE_NAMES) + + def __iter__(self) -> Iterator[str]: + return iter(RULE_NAMES) + + def __contains__(self, key: object) -> bool: + return key in RULE_NAMES_SET + + def keys(self) -> Iterable[str]: + return RULE_NAMES + + def values(self) -> Iterable[object]: + for key in RULE_NAMES: + yield getattr(self, key) + + def items(self) -> Iterable[tuple[str, object]]: + get_rule = self.get_rule + for key in RULE_NAMES: + yield (key, getattr(self, key)) + @property def _cache_key(self) -> int: """A cache key, that changes when any style is changed. diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index 1936284dd2..132087619d 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -130,10 +130,12 @@ def __init__(self, description: str, **tokens: str) -> None: self._expect_semicolon = True def expect_eof(self, eof: bool) -> Expect: + """Expect an end of file.""" self._expect_eof = eof return self def expect_semicolon(self, semicolon: bool) -> Expect: + """Tokenizer expects text to be terminated with a semi-colon.""" self._expect_semicolon = semicolon return self diff --git a/src/textual/strip.py b/src/textual/strip.py index 66c701c79a..2b2697a17d 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -599,6 +599,27 @@ def apply_style(self, style: Style) -> Strip: self._style_cache[style] = styled_strip return styled_strip + def _apply_link_style(self, link_style: Style) -> Strip: + segments = self._segments + _Segment = Segment + segments = [ + ( + _Segment( + text, + ( + style + if style._meta is None + else (style + link_style if "@click" in style.meta else style) + ), + control, + ) + if style + else _Segment(text) + ) + for text, style, control in segments + ] + return Strip(segments, self._cell_length) + def render(self, console: Console) -> str: """Render the strip into terminal sequences. diff --git a/src/textual/visual.py b/src/textual/visual.py index 0d2eeec75a..653faae095 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -13,8 +13,10 @@ from rich.text import Text from textual._context import active_app +from textual.css.styles import RulesMap from textual.geometry import Spacing from textual.render import measure +from textual.selection import Selection from textual.strip import Strip from textual.style import Style @@ -107,15 +109,17 @@ class Visual(ABC): @abstractmethod def render_strips( self, - widget: Widget, + rules: RulesMap, width: int, height: int | None, style: Style, + selection: Selection | None = None, + selection_style: Style | None = None, ) -> list[Strip]: """Render the visual into an iterable of strips. Args: - widget: Parent widget. + rules: A mapping of style rules, such as the Widgets `styles` object. width: Width of desired render. height: Height of desired render or `None` for any height. style: The base style to render on top of. @@ -125,7 +129,7 @@ def render_strips( """ @abstractmethod - def get_optimal_width(self, widget: Widget, container_width: int) -> int: + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: """Get optimal width of the visual to display its content. The exact definition of "optimal width" is dependant on the visual, but @@ -133,7 +137,7 @@ def get_optimal_width(self, widget: Widget, container_width: int) -> int: and without superfluous space. Args: - widget: Parent widget. + rules: A mapping of style rules, such as the Widgets `styles` object. container_width: The size of the container in cells. Returns: @@ -142,11 +146,11 @@ def get_optimal_width(self, widget: Widget, container_width: int) -> int: """ @abstractmethod - def get_height(self, widget: Widget, width: int) -> int: + def get_height(self, rules: RulesMap, width: int) -> int: """Get the height of the visual if rendered with the given width. Args: - widget: Parent widget. + rules: A mapping of style rules, such as the Widgets `styles` object. width: Width of visual in cells. Returns: @@ -177,7 +181,25 @@ def to_strips( Returns: A list of Strips containing the render. """ - strips = visual.render_strips(widget, width, height, style) + + selection = widget.text_selection + if selection is not None: + selection_style: Style | None = Style.from_rich_style( + widget.screen.get_component_rich_style("screen--selection") + ) + else: + selection_style = None + + strips = visual.render_strips( + widget.styles, + width, + height, + style, + selection, + selection_style, + ) + strips = [strip._apply_link_style(widget.link_style) for strip in strips] + if height is None: height = len(strips) rich_style = style.rich_style @@ -228,7 +250,7 @@ def _measure(self, console: Console, options: ConsoleOptions) -> Measurement: ) return self._measurement - def get_optimal_width(self, widget: Widget, container_width: int) -> int: + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: console = active_app.get().console width = measure( console, self._renderable, container_width, container_width=container_width @@ -236,7 +258,7 @@ def get_optimal_width(self, widget: Widget, container_width: int) -> int: return width - def get_height(self, widget: Widget, width: int) -> int: + def get_height(self, rules: RulesMap, width: int) -> int: console = active_app.get().console renderable = self._renderable if isinstance(renderable, Text): @@ -258,10 +280,12 @@ def get_height(self, widget: Widget, width: int) -> int: def render_strips( self, - widget: Widget, + rules: RulesMap, width: int, height: int | None, style: Style, + selection: Selection | None = None, + selection_style: Style | None = None, ) -> list[Strip]: console = active_app.get().console options = console.options.update( @@ -270,7 +294,7 @@ def render_strips( height=height, ) rich_style = style.rich_style - renderable = widget.post_render(self._renderable, rich_style) + renderable = self._widget.post_render(self._renderable, rich_style) segments = console.render(renderable, options.update_width(width)) strips = [ Strip(line) @@ -304,21 +328,22 @@ def __rich_repr__(self) -> rich.repr.Result: yield self._visual yield self._spacing - def get_optimal_width(self, widget: Widget, container_width: int) -> int: + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: return ( - self._visual.get_optimal_width(widget, container_width) - + self._spacing.width + self._visual.get_optimal_width(rules, container_width) + self._spacing.width ) - def get_height(self, widget: Widget, width: int) -> int: - return self._visual.get_height(widget, width) + self._spacing.height + def get_height(self, rules: RulesMap, width: int) -> int: + return self._visual.get_height(rules, width) + self._spacing.height def render_strips( self, - widget: Widget, + rules: RulesMap, width: int, height: int | None, style: Style, + selection: Selection | None = None, + selection_style: Style | None = None, ) -> list[Strip]: padding = self._spacing top, right, bottom, left = self._spacing @@ -327,10 +352,12 @@ def render_strips( return [] strips = self._visual.render_strips( - widget, + rules, render_width, None if height is None else height - padding.height, style, + selection, + selection_style, ) if padding: diff --git a/src/textual/widget.py b/src/textual/widget.py index a1a0890c19..aa2ca7d42a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -642,7 +642,7 @@ def _render_widget(self) -> Widget: return self._cover_widget if self._cover_widget is not None else self @property - def selection(self) -> Selection | None: + def text_selection(self) -> Selection | None: """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) @@ -3949,7 +3949,7 @@ def refresh( Returns: The `Widget` instance. """ - self._layout_cache.clear() + if layout: self._layout_required = True for ancestor in self.ancestors: @@ -3967,6 +3967,7 @@ def refresh( self.check_idle() return self + self._layout_cache.clear() if repaint: self._set_dirty(*regions) self.clear_cached_dimensions() diff --git a/src/textual/widgets/_digits.py b/src/textual/widgets/_digits.py index 431e196847..e3c6e13c31 100644 --- a/src/textual/widgets/_digits.py +++ b/src/textual/widgets/_digits.py @@ -77,7 +77,7 @@ def update(self, value: str) -> None: def render(self) -> RenderResult: """Render digits.""" rich_style = self.rich_style - if self.selection: + if self.text_selection: rich_style += self.selection_style digits = DigitsRenderable(self._value, rich_style) text_align = self.styles.text_align diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index 360fdbf821..2504aff6ab 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -320,8 +320,8 @@ def _render_line_strip(self, y: int, rich_style: Style) -> Strip: Returns: An uncropped Strip. """ - selection = self.selection - if y in self._render_line_cache and self.selection is None: + selection = self.text_selection + if y in self._render_line_cache and selection is None: return self._render_line_cache[y] _line = self._process_line(self._lines[y])