ai-station/.venv/lib/python3.12/site-packages/textual/visual.py

426 lines
13 KiB
Python
Raw Permalink Normal View History

2025-12-25 14:54:33 +00:00
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from itertools import islice
from typing import TYPE_CHECKING, Callable, Protocol
import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType
from rich.measure import Measurement
from rich.protocol import is_renderable, rich_cast
from rich.segment import Segment
from rich.style import Style as RichStyle
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
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from textual.widget import Widget
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")
@dataclass(frozen=True)
class RenderOptions:
"""Additional options passed to `Visual.render_strips`."""
get_style: Callable[[str | Style], Style]
"""Callable to get a style."""
rules: RulesMap
"""Mapping of style rules."""
selection: Selection | None = None
"""Text selection information."""
selection_style: Style | None = None
"""Style of text selection."""
post_style: Style | None = None
"""Optional style to apply post render."""
# Note: not runtime checkable currently, as I've found that to be slow
class SupportsVisual(Protocol):
"""An object that supports the textualize protocol."""
def visualize(self, widget: Widget, obj: object) -> Visual | None:
"""Convert the result of a Widget.render() call into a Visual, using the Visual protocol.
Args:
widget: The widget that generated the render.
obj: The result of the render.
Returns:
A Visual instance, or `None` if it wasn't possible.
"""
class VisualError(Exception):
"""An error with the visual protocol."""
VisualType: TypeAlias = "RenderableType | SupportsVisual | Visual"
def visualize(widget: Widget, obj: object, markup: bool = True) -> Visual:
"""Get a visual instance from an object.
If the object does not support the Visual protocol and is a Rich renderable, it
will be wrapped in a [RichVisual][textual.visual.RichVisual].
Args:
widget: The parent widget.
obj: An object.
markup: Enable markup.
Raises:
VisualError: If there is no Visual could be returned to render `obj`.
Returns:
A Visual instance to render the object, or `None` if there is no associated visual.
"""
_rich_traceback_omit = True
if isinstance(obj, Visual):
# Already a visual
return obj
# The visualize method should return a Visual if present.
visualize = getattr(obj, "visualize", None)
if visualize is None:
# Doesn't expose the textualize protocol
from textual.content import Content
if isinstance(obj, str):
return Content.from_markup(obj) if markup else Content(obj)
if is_renderable(obj):
if isinstance(obj, Text):
return Content.from_rich_text(obj, console=widget.app.console)
# If its is a Rich renderable, wrap it with a RichVisual
return RichVisual(widget, rich_cast(obj))
else:
# We don't know how to make a visual from this object
raise VisualError(
f"unable to display {obj.__class__.__name__!r} type; must be a str, Rich renderable, or Textual Visual object"
)
# Call the textualize method to create a visual
visual = visualize()
if not isinstance(visual, Visual) and is_renderable(visual):
return RichVisual(widget, visual)
return visual
class Visual(ABC):
"""A Textual 'Visual' object.
Analogous to a Rich renderable, but with support for transparency.
"""
@abstractmethod
def render_strips(
self, width: int, height: int | None, style: Style, options: RenderOptions
) -> list[Strip]:
"""Render the Visual into an iterable of strips.
Args:
width: Width of desired render.
height: Height of desired render or `None` for any height.
style: The base style to render on top of.
options: Additional render options.
Returns:
An list of Strips.
"""
@abstractmethod
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
will typically be wide enough to display output without cropping or wrapping,
and without superfluous space.
Args:
rules: A mapping of style rules, such as the Widgets `styles` object.
container_width: The width of the container, used by Rich Renderables.
May be ignored for Textual Visuals.
Returns:
A width in cells.
"""
def get_minimal_width(self, rules: RulesMap) -> int:
"""Get a minimal width (the smallest width before data loss occurs).
Args:
rules: A mapping of style rules, such as the Widgets `styles` object.
container_width: The width of the container, used by Rich Renderables.
May be ignored for Textual Visuals.
Returns:
A width in cells.
"""
return 1
@abstractmethod
def get_height(self, rules: RulesMap, width: int) -> int:
"""Get the height of the Visual if rendered at the given width.
Args:
rules: A mapping of style rules, such as the Widgets `styles` object.
width: Width of visual in cells.
Returns:
A height in lines.
"""
@classmethod
def to_strips(
cls,
widget: Widget,
visual: Visual,
width: int,
height: int | None,
style: Style,
*,
apply_selection: bool = True,
pad: bool = False,
post_style: Style | None = None,
) -> list[Strip]:
"""High level function to render a visual to strips.
Args:
widget: Widget that produced the visual.
visual: A Visual instance.
width: Desired width (in cells).
height: Desired height (in lines) or `None` for no limit.
style: A (Visual) Style instance.
apply_selection: Automatically apply selection styles?
pad: Pad to desired width?
post_style: Optional Style to apply to strips after rendering.
Returns:
A list of Strips containing the render.
"""
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(
width,
height,
style,
RenderOptions(
widget._get_style,
widget.styles,
selection if apply_selection else None,
selection_style,
),
)
strips = [strip._apply_link_style(widget.link_style) for strip in strips]
if height is None:
height = len(strips)
rich_style = (style + Style(reverse=False)).rich_style
if pad:
strips = [strip.extend_cell_length(width, rich_style) for strip in strips]
content_align = widget.styles.content_align
if content_align != ("left", "top"):
align_horizontal, align_vertical = content_align
strips = list(
Strip.align(
strips,
rich_style,
width,
height,
align_horizontal,
align_vertical,
)
)
return strips
@rich.repr.auto
class RichVisual(Visual):
"""A Visual to wrap a Rich renderable."""
def __init__(self, widget: Widget, renderable: RenderableType) -> None:
"""
Args:
widget: The associated Widget.
renderable: A Rich renderable.
"""
self._widget = widget
self._renderable = renderable
self._measurement: Measurement | None = None
def __rich_repr__(self) -> rich.repr.Result:
yield self._widget
yield self._renderable
def _measure(self, console: Console, options: ConsoleOptions) -> Measurement:
if self._measurement is None:
self._measurement = Measurement.get(
console,
options,
self._widget.post_render(self._renderable, RichStyle.null()),
)
return self._measurement
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
)
return width
def get_height(self, rules: RulesMap, width: int) -> int:
app = active_app.get()
console = app.console
renderable = self._renderable
if isinstance(renderable, Text):
height = len(
Text(renderable.plain).wrap(
console,
width,
no_wrap=renderable.no_wrap,
tab_size=renderable.tab_size or 8,
)
)
else:
console_options = app.console_options
options = console_options.update_width(width).update(highlight=False)
segments = console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum([text.count("\n") for text, _, _ in segments])
return height
def render_strips(
self, width: int, height: int | None, style: Style, options: RenderOptions
) -> list[Strip]:
"""Render the Visual into an iterable of strips. Part of the Visual protocol.
Args:
width: Width of desired render.
height: Height of desired render or `None` for any height.
style: The base style to render on top of.
options: Additional render options.
Returns:
An list of Strips.
"""
app = active_app.get()
console = app.console
console_options = app.console_options.update(
highlight=False,
width=width,
height=height,
)
rich_style = style.rich_style
renderable = self._widget.post_render(self._renderable, rich_style)
segments = console.render(renderable, console_options.update_width(width))
strips = [
Strip(line)
for line in islice(
Segment.split_and_crop_lines(
segments, width, include_new_lines=False, pad=False
),
None,
height,
)
]
return strips
@rich.repr.auto
class Padding(Visual):
"""A Visual to pad another visual."""
def __init__(self, visual: Visual, spacing: Spacing) -> None:
"""
Args:
Visual: A Visual.
spacing: A Spacing object containing desired padding dimensions.
"""
self._visual = visual
self._spacing = spacing
def __rich_repr__(self) -> rich.repr.Result:
yield self._visual
yield self._spacing
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
return (
self._visual.get_optimal_width(rules, container_width) + self._spacing.width
)
def get_height(self, rules: RulesMap, width: int) -> int:
return (
self._visual.get_height(rules, width - self._spacing.width)
+ self._spacing.height
)
def render_strips(
self, width: int, height: int | None, style: Style, options: RenderOptions
) -> list[Strip]:
"""Render the Visual into an iterable of strips. Part of the Visual protocol.
Args:
width: Width of desired render.
height: Height of desired render or `None` for any height.
style: The base style to render on top of.
options: Additional render options.
Returns:
An list of Strips.
"""
padding = self._spacing
top, right, bottom, left = self._spacing
render_width = width - (left + right)
if render_width <= 0:
return []
strips = self._visual.render_strips(
render_width,
None if height is None else height - padding.height,
style,
options,
)
if padding:
rich_style = style.rich_style
top_padding = [Strip.blank(width, rich_style)] * top if top else []
bottom_padding = [Strip.blank(width, rich_style)] * bottom if bottom else []
strips = [
*top_padding,
*[
strip.crop_pad(render_width, left, right, rich_style)
for strip in strips
],
*bottom_padding,
]
return strips