504 lines
18 KiB
Python
504 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING, Callable, Iterable, Sequence
|
|
|
|
import rich.repr
|
|
from rich.segment import Segment
|
|
from rich.style import Style as RichStyle
|
|
from rich.terminal_theme import TerminalTheme
|
|
|
|
from textual import log
|
|
from textual._ansi_theme import DEFAULT_TERMINAL_THEME
|
|
from textual._border import get_box, render_border_label, render_row
|
|
from textual._context import active_app
|
|
from textual._opacity import _apply_opacity
|
|
from textual._segment_tools import apply_hatch, line_pad, line_trim, make_blank
|
|
from textual.color import TRANSPARENT, Color
|
|
from textual.constants import DEBUG
|
|
from textual.content import Content
|
|
from textual.filter import LineFilter
|
|
from textual.geometry import Region, Size, Spacing
|
|
from textual.renderables.text_opacity import TextOpacity
|
|
from textual.renderables.tint import Tint
|
|
from textual.strip import Strip
|
|
from textual.style import Style
|
|
|
|
if TYPE_CHECKING:
|
|
from typing_extensions import TypeAlias
|
|
|
|
from textual.css.styles import StylesBase
|
|
from textual.widget import Widget
|
|
|
|
RenderLineCallback: TypeAlias = Callable[[int], Strip]
|
|
|
|
|
|
@rich.repr.auto(angular=True)
|
|
class StylesCache:
|
|
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
|
|
|
|
The render method applies border, outline, and padding set in the Styles object to widget content.
|
|
|
|
The diagram below shows content (possibly from a Rich renderable) with padding and border. The
|
|
labels A. B. and C. indicate the code path (see comments in render_line below) chosen to render
|
|
the indicated lines.
|
|
|
|
```
|
|
┏━━━━━━━━━━━━━━━━━━━━━━┓◀── A. border
|
|
┃ ┃◀┐
|
|
┃ ┃ └─ B. border + padding +
|
|
┃ Lorem ipsum dolor ┃◀┐ border
|
|
┃ sit amet, ┃ │
|
|
┃ consectetur ┃ └─ C. border + padding +
|
|
┃ adipiscing elit, ┃ content + padding +
|
|
┃ sed do eiusmod ┃ border
|
|
┃ tempor incididunt ┃
|
|
┃ ┃
|
|
┃ ┃
|
|
┗━━━━━━━━━━━━━━━━━━━━━━┛
|
|
```
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._cache: dict[int, Strip] = {}
|
|
self._dirty_lines: set[int] = set()
|
|
self._width = 1
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
if self._dirty_lines:
|
|
yield "dirty", self._dirty_lines
|
|
yield "width", self._width, 1
|
|
|
|
def set_dirty(self, *regions: Region) -> None:
|
|
"""Add a dirty regions."""
|
|
if regions:
|
|
for region in regions:
|
|
self._dirty_lines.update(region.line_range)
|
|
else:
|
|
self.clear()
|
|
|
|
def is_dirty(self, y: int) -> bool:
|
|
"""Check if a given line is dirty (needs to be rendered again).
|
|
|
|
Args:
|
|
y: Y coordinate of line.
|
|
|
|
Returns:
|
|
True if line requires a render, False if can be cached.
|
|
"""
|
|
return y in self._dirty_lines
|
|
|
|
def clear(self) -> None:
|
|
"""Clear the styles cache (will cause the content to re-render)."""
|
|
|
|
self._cache.clear()
|
|
self._dirty_lines.clear()
|
|
|
|
def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
|
|
"""Render the content for a widget.
|
|
|
|
Args:
|
|
widget: A widget.
|
|
region: A region of the widget to render.
|
|
|
|
Returns:
|
|
Rendered lines.
|
|
"""
|
|
border_title = widget._border_title
|
|
border_subtitle = widget._border_subtitle
|
|
|
|
base_background, background = widget.background_colors
|
|
styles = widget.styles
|
|
strips = self.render(
|
|
styles,
|
|
widget.region.size,
|
|
base_background,
|
|
background,
|
|
widget.render_line,
|
|
widget.get_line_filters(),
|
|
(
|
|
None
|
|
if border_title is None
|
|
else (
|
|
border_title,
|
|
*widget._get_title_style_information(base_background),
|
|
)
|
|
),
|
|
(
|
|
None
|
|
if border_subtitle is None
|
|
else (
|
|
border_subtitle,
|
|
*widget._get_subtitle_style_information(base_background),
|
|
)
|
|
),
|
|
content_size=widget.content_region.size,
|
|
padding=styles.padding,
|
|
crop=crop,
|
|
opacity=widget.opacity,
|
|
ansi_theme=widget.app.ansi_theme,
|
|
)
|
|
|
|
if widget.auto_links:
|
|
hover_style = widget.hover_style
|
|
if (
|
|
hover_style._link_id
|
|
and hover_style._meta
|
|
and "@click" in hover_style.meta
|
|
):
|
|
link_style_hover = widget.link_style_hover
|
|
if link_style_hover:
|
|
strips = [
|
|
strip.style_links(hover_style.link_id, link_style_hover)
|
|
for strip in strips
|
|
]
|
|
|
|
return strips
|
|
|
|
def render(
|
|
self,
|
|
styles: StylesBase,
|
|
size: Size,
|
|
base_background: Color,
|
|
background: Color,
|
|
render_content_line: RenderLineCallback,
|
|
filters: Sequence[LineFilter],
|
|
border_title: tuple[Content, Color, Color, Style] | None,
|
|
border_subtitle: tuple[Content, Color, Color, Style] | None,
|
|
content_size: Size | None = None,
|
|
padding: Spacing | None = None,
|
|
crop: Region | None = None,
|
|
opacity: float = 1.0,
|
|
ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME,
|
|
) -> list[Strip]:
|
|
"""Render a widget content plus CSS styles.
|
|
|
|
Args:
|
|
styles: CSS Styles object.
|
|
size: Size of widget.
|
|
base_background: Background color beneath widget.
|
|
background: Background color of widget.
|
|
render_content_line: Callback to render content line.
|
|
console: The console in use by the app.
|
|
border_title: Optional tuple of (title, color, background, style).
|
|
border_subtitle: Optional tuple of (subtitle, color, background, style).
|
|
content_size: Size of content or None to assume full size.
|
|
padding: Override padding from Styles, or None to use styles.padding.
|
|
crop: Region to crop to.
|
|
filters: Additional post-processing for the segments.
|
|
opacity: Widget opacity.
|
|
ansi_theme: Theme for ANSI colors.
|
|
|
|
Returns:
|
|
Rendered lines.
|
|
"""
|
|
if content_size is None:
|
|
content_size = size
|
|
if padding is None:
|
|
padding = styles.padding
|
|
if crop is None:
|
|
crop = size.region
|
|
|
|
width, _height = size
|
|
if width != self._width:
|
|
self.clear()
|
|
self._width = width
|
|
strips: list[Strip] = []
|
|
add_strip = strips.append
|
|
|
|
is_dirty = self._dirty_lines.__contains__
|
|
render_line = self.render_line
|
|
|
|
for y in crop.line_range:
|
|
if is_dirty(y) or y not in self._cache:
|
|
strip = render_line(
|
|
styles,
|
|
y,
|
|
size,
|
|
content_size,
|
|
padding,
|
|
base_background,
|
|
background,
|
|
render_content_line,
|
|
border_title,
|
|
border_subtitle,
|
|
opacity,
|
|
ansi_theme,
|
|
)
|
|
self._cache[y] = strip
|
|
else:
|
|
strip = self._cache[y]
|
|
|
|
for filter in filters:
|
|
strip = strip.apply_filter(filter, background)
|
|
|
|
if DEBUG:
|
|
if any([not (segment.control or segment.text) for segment in strip]):
|
|
log.warning(f"Strip contains invalid empty Segments: {strip!r}.")
|
|
|
|
add_strip(strip)
|
|
|
|
self._dirty_lines.difference_update(crop.line_range)
|
|
|
|
if crop.column_span != (0, width):
|
|
x1, x2 = crop.column_span
|
|
strips = [strip.crop(x1, x2) for strip in strips]
|
|
|
|
return strips
|
|
|
|
@lru_cache(1024)
|
|
def get_inner_outer(
|
|
cls, base_background: Color, background: Color
|
|
) -> tuple[Style, Style]:
|
|
"""Get inner and outer background colors."""
|
|
return (
|
|
Style(background=base_background + background),
|
|
Style(background=base_background),
|
|
)
|
|
|
|
def render_line(
|
|
self,
|
|
styles: StylesBase,
|
|
y: int,
|
|
size: Size,
|
|
content_size: Size,
|
|
padding: Spacing,
|
|
base_background: Color,
|
|
background: Color,
|
|
render_content_line: Callable[[int], Strip],
|
|
border_title: tuple[Content, Color, Color, Style] | None,
|
|
border_subtitle: tuple[Content, Color, Color, Style] | None,
|
|
opacity: float,
|
|
ansi_theme: TerminalTheme,
|
|
) -> Strip:
|
|
"""Render a styled line.
|
|
|
|
Args:
|
|
styles: Styles object.
|
|
y: The y coordinate of the line (relative to widget screen offset).
|
|
size: Size of the widget.
|
|
content_size: Size of the content area.
|
|
padding: Padding.
|
|
base_background: Background color of widget beneath this line.
|
|
background: Background color of widget.
|
|
render_content_line: Callback to render a line of content.
|
|
console: The console in use by the app.
|
|
border_title: Optional tuple of (title, color, background, style).
|
|
border_subtitle: Optional tuple of (subtitle, color, background, style).
|
|
opacity: Opacity of line.
|
|
|
|
Returns:
|
|
A line of segments.
|
|
"""
|
|
|
|
gutter = styles.gutter
|
|
width, height = size
|
|
content_width, content_height = content_size
|
|
|
|
pad_top, pad_right, pad_bottom, pad_left = padding
|
|
|
|
(
|
|
(border_top, border_top_color),
|
|
(border_right, border_right_color),
|
|
(border_bottom, border_bottom_color),
|
|
(border_left, border_left_color),
|
|
) = styles.border
|
|
|
|
(
|
|
(outline_top, outline_top_color),
|
|
(outline_right, outline_right_color),
|
|
(outline_bottom, outline_bottom_color),
|
|
(outline_left, outline_left_color),
|
|
) = styles.outline
|
|
|
|
from_color = RichStyle.from_color
|
|
inner, outer = self.get_inner_outer(base_background, background)
|
|
|
|
def line_post(segments: Iterable[Segment]) -> Iterable[Segment]:
|
|
"""Apply effects to segments inside the border."""
|
|
if styles.has_rule("hatch") and styles.hatch != "none":
|
|
character, color = styles.hatch
|
|
if character != " " and color.a > 0:
|
|
hatch_style = from_color(
|
|
(background + color).rich_color, background.rich_color
|
|
)
|
|
return apply_hatch(segments, character, hatch_style)
|
|
return segments
|
|
|
|
def post(segments: Iterable[Segment]) -> Iterable[Segment]:
|
|
"""Post process segments to apply opacity and tint.
|
|
|
|
Args:
|
|
segments: Iterable of segments.
|
|
|
|
Returns:
|
|
New list of segments
|
|
"""
|
|
try:
|
|
app = active_app.get()
|
|
ansi_theme = app.ansi_theme
|
|
except LookupError:
|
|
ansi_theme = DEFAULT_TERMINAL_THEME
|
|
|
|
if styles.tint.a:
|
|
segments = Tint.process_segments(
|
|
segments, styles.tint, ansi_theme, background
|
|
)
|
|
if opacity != 1.0:
|
|
segments = _apply_opacity(segments, base_background, opacity)
|
|
return segments
|
|
|
|
line: Iterable[Segment]
|
|
# Draw top or bottom borders (A)
|
|
if (border_top and y == 0) or (border_bottom and y == height - 1):
|
|
is_top = y == 0
|
|
border_color = base_background + (
|
|
border_top_color if is_top else border_bottom_color
|
|
).multiply_alpha(opacity)
|
|
border_color_as_style = Style(foreground=border_color)
|
|
border_edge_type = border_top if is_top else border_bottom
|
|
has_left = border_left != ""
|
|
has_right = border_right != ""
|
|
border_label = border_title if is_top else border_subtitle
|
|
if border_label is None:
|
|
render_label = None
|
|
else:
|
|
label, label_color, label_background, style = border_label
|
|
base_label_background = base_background + background
|
|
style += Style(
|
|
(
|
|
(base_label_background + label_background)
|
|
if label_background.a
|
|
else TRANSPARENT
|
|
),
|
|
(
|
|
(base_label_background + label_color)
|
|
if label_color.a
|
|
else TRANSPARENT
|
|
),
|
|
)
|
|
render_label = (label, style)
|
|
|
|
# Try to save time with expensive call to `render_border_label`:
|
|
if render_label:
|
|
label_segments = render_border_label(
|
|
render_label,
|
|
is_top,
|
|
border_edge_type,
|
|
width - 2,
|
|
inner,
|
|
outer,
|
|
border_color_as_style,
|
|
has_left,
|
|
has_right,
|
|
)
|
|
else:
|
|
label_segments = []
|
|
box_segments = get_box(
|
|
border_edge_type,
|
|
inner,
|
|
outer,
|
|
border_color_as_style,
|
|
)
|
|
label_alignment = (
|
|
styles.border_title_align if is_top else styles.border_subtitle_align
|
|
)
|
|
line = render_row(
|
|
box_segments[0 if is_top else 2],
|
|
width,
|
|
has_left,
|
|
has_right,
|
|
label_segments,
|
|
label_alignment, # type: ignore
|
|
)
|
|
|
|
# Draw padding (B)
|
|
elif (pad_top and y < gutter.top) or (
|
|
pad_bottom and y >= height - gutter.bottom
|
|
):
|
|
background_rich_style = inner.rich_style
|
|
left_style = Style(
|
|
foreground=base_background + border_left_color.multiply_alpha(opacity)
|
|
)
|
|
left = get_box(border_left, inner, outer, left_style)[1][0]
|
|
right_style = Style(
|
|
foreground=base_background + border_right_color.multiply_alpha(opacity)
|
|
)
|
|
right = get_box(border_right, inner, outer, right_style)[1][2]
|
|
if border_left and border_right:
|
|
line = [left, make_blank(width - 2, background_rich_style), right]
|
|
elif border_left:
|
|
line = [left, make_blank(width - 1, background_rich_style)]
|
|
elif border_right:
|
|
line = [make_blank(width - 1, background_rich_style), right]
|
|
else:
|
|
line = [make_blank(width, background_rich_style)]
|
|
line = line_post(line)
|
|
else:
|
|
# Content with border and padding (C)
|
|
content_y = y - gutter.top
|
|
if content_y < content_height:
|
|
line = render_content_line(y - gutter.top)
|
|
line = line.adjust_cell_length(content_width, inner.rich_style)
|
|
else:
|
|
line = Strip.blank(content_width, inner.rich_style)
|
|
|
|
if (text_opacity := styles.text_opacity) != 1.0:
|
|
line = TextOpacity.process_segments(line, text_opacity, ansi_theme)
|
|
line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style))
|
|
|
|
if border_left or border_right:
|
|
# Add left / right border
|
|
left_style = Style(
|
|
foreground=base_background
|
|
+ border_left_color.multiply_alpha(opacity)
|
|
)
|
|
left = get_box(border_left, inner, outer, left_style)[1][0]
|
|
right_style = Style(
|
|
foreground=base_background
|
|
+ border_right_color.multiply_alpha(opacity)
|
|
)
|
|
right = get_box(border_right, inner, outer, right_style)[1][2]
|
|
|
|
if border_left and border_right:
|
|
line = [left, *line, right]
|
|
elif border_left:
|
|
line = [left, *line]
|
|
else:
|
|
line = [*line, right]
|
|
|
|
# Draw any outline
|
|
if (outline_top and y == 0) or (outline_bottom and y == height - 1):
|
|
# Top or bottom outlines
|
|
outline_color = outline_top_color if y == 0 else outline_bottom_color
|
|
box_segments = get_box(
|
|
outline_top if y == 0 else outline_bottom,
|
|
inner,
|
|
outer,
|
|
Style(foreground=base_background + outline_color),
|
|
)
|
|
line = render_row(
|
|
box_segments[0 if y == 0 else 2],
|
|
width,
|
|
outline_left != "",
|
|
outline_right != "",
|
|
(),
|
|
)
|
|
|
|
elif outline_left or outline_right:
|
|
# Lines in side outline
|
|
left_style = Style(foreground=(base_background + outline_left_color))
|
|
left = get_box(outline_left, inner, outer, left_style)[1][0]
|
|
right_style = Style(foreground=(base_background + outline_right_color))
|
|
right = get_box(outline_right, inner, outer, right_style)[1][2]
|
|
line = line_trim(list(line), outline_left != "", outline_right != "")
|
|
if outline_left and outline_right:
|
|
line = [left, *line, right]
|
|
elif outline_left:
|
|
line = [left, *line]
|
|
else:
|
|
line = [*line, right]
|
|
|
|
strip = Strip(post(line), width)
|
|
return strip
|