diff --git a/src/textual/command.py b/src/textual/command.py index 9464c16b04..be0433102e 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -1021,11 +1021,9 @@ async def _gather_commands(self, search_value: str) -> None: # Turn the command into something for display, and add it to the # list of commands that have been gathered so far. - prompt = hit.prompt - - content = Content(prompt) + prompt = Content(hit.prompt, no_wrap=True, ellipsis=True) if hit.help: - prompt = content.append("\n").append( + prompt = prompt.append("\n").append( Content.styled(hit.help, help_style) ) diff --git a/src/textual/content.py b/src/textual/content.py index 103193dc40..92b773c702 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -12,7 +12,7 @@ import re from operator import itemgetter -from typing import Callable, Iterable, NamedTuple, Sequence +from typing import TYPE_CHECKING, Callable, Iterable, NamedTuple, Sequence import rich.repr from rich._wrap import divide_line @@ -23,10 +23,13 @@ from textual._cells import cell_len from textual._loop import loop_last from textual.color import Color -from textual.css.styles import Styles +from textual.css.types import TextAlign from textual.strip import Strip from textual.visual import Style, Visual +if TYPE_CHECKING: + from textual.widget import Widget + _re_whitespace = re.compile(r"\s+$") @@ -136,19 +139,38 @@ def __init__( text: str, spans: list[Span] | None = None, cell_length: int | None = None, + justify: TextAlign = "left", + no_wrap: bool = False, + ellipsis: bool = False, ) -> None: self._text: str = text self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length + self._justify = justify + self._no_wrap = no_wrap + self._ellipsis = ellipsis @classmethod def styled( - cls, text: str, style: Style | str = "", cell_length: int | None = None + cls, + text: str, + style: Style | str = "", + cell_length: int | None = None, + justify: TextAlign = "left", + no_wrap: bool = False, + ellipsis: bool = False, ) -> Content: if not text: return Content("") span_length = cell_len(text) if cell_length is None else cell_length - new_content = cls(text, [Span(0, span_length, style)], span_length) + new_content = cls( + text, + [Span(0, span_length, style)], + span_length, + justify=justify, + no_wrap=no_wrap, + ellipsis=ellipsis, + ) return new_content def get_optimal_width(self, tab_size: int = 8) -> int: @@ -164,27 +186,24 @@ def textualize(self) -> Content: def render_strips( self, + widget: Widget, width: int, height: int | None, - base_style: Style, - styles: Styles, + style: Style, ) -> list[Strip]: - horizontal_align = styles.text_align - justify = self._NORMALIZE_TEXT_ALIGN.get(horizontal_align, horizontal_align) lines = self.wrap( width, - justify=justify, # type: ignore[arg-type] - overflow="fold", + justify=self._justify, + overflow=( + ("ellipsis" if self._ellipsis else "crop") if self._no_wrap else "fold" + ), no_wrap=False, tab_size=8, ) if height is not None: lines = lines[:height] - base_style += Style.from_styles(styles) - return [ - Strip(line.render_segments(base_style), line.cell_length) for line in lines - ] + return [Strip(line.render_segments(style), line.cell_length) for line in lines] def get_height(self, width: int) -> int: lines = self.wrap(width) @@ -371,9 +390,7 @@ def get_style_at_offset(self, offset: int) -> Style: style = Style() for start, end, span_style in self._spans: if end > offset >= start: - print(style, span_style) style += span_style - print("---", style) return style def truncate( @@ -800,7 +817,7 @@ def expand_tabs(self, tab_size: int = 8) -> Content: def wrap( self, width: int, - justify: JustifyMethod = "left", + justify: TextAlign = "left", overflow: OverflowMethod = "fold", no_wrap: bool = False, tab_size: int = 8, diff --git a/src/textual/visual.py b/src/textual/visual.py index 3fe7f63494..302eaf4385 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from abc import ABC, abstractmethod from dataclasses import dataclass from functools import cached_property, lru_cache @@ -22,18 +21,17 @@ from textual.geometry import Spacing from textual.strip import Strip -if sys.version_info >= (3, 8): - pass -else: - pass - - if TYPE_CHECKING: from textual.widget import Widget _NULL_RICH_STYLE = RichStyle() +def is_visual(obj: object) -> bool: + """Check if the given object is a Visual or supports the Visual protocol.""" + return isinstance(obj, Visual) or hasattr(obj, "textualize") + + class SupportsTextualize(Protocol): """An object that supports the textualize protocol.""" @@ -44,7 +42,7 @@ class VisualError(Exception): """An error with the visual protocol.""" -VisualType = RenderableType | SupportsTextualize +VisualType = RenderableType | SupportsTextualize | "Visual" def visualize(widget: Widget, obj: object) -> Visual: @@ -189,11 +187,7 @@ class Visual(ABC): @abstractmethod def render_strips( - self, - width: int, - height: int | None, - base_style: Style, - styles: Styles, + self, widget: Widget, width: int, height: int | None, style: Style ) -> list[Strip]: """Render the visual in to an iterable of strips. @@ -246,16 +240,12 @@ def to_strips( widget: Widget, component_classes: list[str] | None = None, ) -> list[Strip]: - styles: Styles - if component_classes: - rules = widget.styles.get_rules() - rules |= widget.get_component_styles(*component_classes).get_rules() - styles = Styles(widget, rules) - else: - styles = widget.styles - - strips = visual.render_strips(width, height, widget.visual_style, styles) - + visual_style = ( + widget.get_visual_style(*component_classes) + if component_classes + else widget.get_visual_style() + ) + strips = visual.render_strips(widget, width, height, visual_style) return strips @@ -307,10 +297,10 @@ def get_height(self, width: int) -> int: def render_strips( self, + widget: Widget, width: int, height: int | None, - base_style: Style, - styles: Styles, + style: Style, ) -> list[Strip]: console = active_app.get().console options = console.options.update( @@ -318,12 +308,16 @@ def render_strips( width=width, height=height, ) - renderable = self._widget.post_render(self._renderable) + renderable = widget.post_render(self._renderable) segments = console.render(renderable, options) + rich_style = style.rich_style + if rich_style: + segments = Segment.apply_style(segments, style=rich_style) + strips = [ - Strip(segments) - for segments in islice( + Strip(line) + for line in islice( Segment.split_and_crop_lines( segments, width, include_new_lines=False, pad=False ), @@ -350,21 +344,21 @@ def get_height(self, width: int) -> int: def render_strips( self, + widget: Widget, width: int, height: int | None, - base_style: Style, - styles: Styles, + style: Style, ) -> list[Strip]: padding = self._spacing top, right, bottom, left = self._spacing render_width = width - (left + right) strips = self._visual.render_strips( + widget, render_width, None if height is None else height - padding.height, - base_style, - styles, + style, ) - rich_style = base_style.rich_style + rich_style = style.rich_style if padding: top_padding = [Strip.blank(width, rich_style)] * top if top else [] bottom_padding = [Strip.blank(width, rich_style)] * bottom if bottom else [] diff --git a/src/textual/widget.py b/src/textual/widget.py index 03b4b524a1..9b2438b4b8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -40,6 +40,8 @@ from rich.text import Text from typing_extensions import Self +from textual.css.styles import StylesBase + if TYPE_CHECKING: from textual.app import RenderResult @@ -83,7 +85,7 @@ from textual.rlock import RLock from textual.strip import Strip from textual.visual import Style as VisualStyle -from textual.visual import Visual, visualize +from textual.visual import Visual, is_visual, visualize if TYPE_CHECKING: from textual.app import App, ComposeResult @@ -1013,6 +1015,49 @@ def get_component_rich_style(self, *names: str, partial: bool = False) -> Style: return partial_style if partial else style + def get_visual_style(self, *names: str) -> VisualStyle: + background = Color(0, 0, 0, 0) + color = Color(255, 255, 255, 0) + + style = Style() + opacity = 1.0 + + def iter_styles() -> Iterable[StylesBase]: + for node in reversed(self.ancestors_with_self): + yield node.styles + for name in names: + yield node.get_component_styles(name) + + for styles in iter_styles(): + has_rule = styles.has_rule + opacity *= styles.opacity + if has_rule("background"): + text_background = background + styles.background.tint( + styles.background_tint + ) + background += ( + styles.background.tint(styles.background_tint) + ).multiply_alpha(opacity) + else: + text_background = background + if has_rule("color"): + color = styles.color + style += styles.text_style + if has_rule("auto_color") and styles.auto_color: + color = text_background.get_contrast_text(color.a) + + visual_style = VisualStyle( + background, + color, + bold=style.bold, + dim=style.dim, + italic=style.italic, + underline=style.underline, + strike=style.strike, + ) + + return visual_style + def render_str(self, text_content: str | Text) -> Text: """Convert str in to a Text object. @@ -3500,6 +3545,7 @@ def _pseudo_classes_cache_key(self) -> tuple[int, ...]: def _get_justify_method(self) -> JustifyMethod | None: """Get the justify method that may be passed to a Rich renderable.""" text_justify: JustifyMethod | None = None + if self.styles.has_rule("text_align"): text_align: JustifyMethod = cast(JustifyMethod, self.styles.text_align) text_justify = _JUSTIFY_MAP.get(text_align, text_align) @@ -3923,6 +3969,9 @@ def _render(self) -> Visual: A renderable. """ renderable = self.render() + if not is_visual(renderable): + renderable = self.post_render(renderable) + visual = visualize(self, renderable) return visual diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 31e39aed49..8a17067cbc 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -480,7 +480,6 @@ def _render_option_content( visual = visualize(self, content) padding = self.get_component_styles("option-list--option").padding if padding: - self.notify(str(padding)) visual = Padding(visual, padding) strips = Visual.to_strips(