mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
apply style to widget
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
}
|
||||
|
||||
*:hover {
|
||||
tint: 30% red;
|
||||
/* tint: 30% red;
|
||||
/* outline: heavy red; */
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ class BasicApp(App, css_path="basic.css"):
|
||||
Static(Syntax(CODE, "python"), classes="code"),
|
||||
classes="scrollable",
|
||||
),
|
||||
# table,
|
||||
table,
|
||||
Error(),
|
||||
Tweet(TweetBody(), classes="scrollbar-size-custom"),
|
||||
Warning(),
|
||||
|
||||
@@ -131,6 +131,17 @@ def get_box(
|
||||
def render_row(
|
||||
box_row: tuple[Segment, Segment, Segment], width: int, left: bool, right: bool
|
||||
) -> list[Segment]:
|
||||
"""Render a top, or bottom border row.
|
||||
|
||||
Args:
|
||||
box_row (tuple[Segment, Segment, Segment]): Corners and side segments.
|
||||
width (int): Total width of resulting line.
|
||||
left (bool): Render left corner.
|
||||
right (bool): Render right corner.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A list of segments.
|
||||
"""
|
||||
box1, box2, box3 = box_row
|
||||
if left and right:
|
||||
return [box1, Segment(box2.text * (width - 2), box2.style), box3]
|
||||
|
||||
@@ -95,7 +95,7 @@ def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]:
|
||||
|
||||
def line_pad(
|
||||
segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style
|
||||
) -> Iterable[Segment]:
|
||||
) -> list[Segment]:
|
||||
"""Adds padding to the left and / or right of a list of segments.
|
||||
|
||||
Args:
|
||||
@@ -123,4 +123,4 @@ def line_pad(
|
||||
*segments,
|
||||
Segment(" " * pad_right, style),
|
||||
]
|
||||
return segments
|
||||
return list(segments)
|
||||
|
||||
305
src/textual/_styles_cache.py
Normal file
305
src/textual/_styles_cache.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Callable, Iterable
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._border import get_box, render_row
|
||||
from ._segment_tools import line_crop, line_pad, line_trim
|
||||
from ._types import Lines
|
||||
from .color import Color
|
||||
from .geometry import Region, Size
|
||||
from .renderables.opacity import Opacity
|
||||
from .renderables.tint import Tint
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else: # pragma: no cover
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .css.styles import StylesBase
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
RenderLineCallback: TypeAlias = Callable[[int], list[Segment]]
|
||||
|
||||
|
||||
class StylesCache:
|
||||
"""Responsible for rendering CSS Styles and keeping a cached of rendered 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, list[Segment]] = {}
|
||||
self._dirty_lines: set[int] = set()
|
||||
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:
|
||||
return y in self._dirty_lines
|
||||
|
||||
def clear(self) -> None:
|
||||
self._cache.clear()
|
||||
self._dirty_lines.clear()
|
||||
|
||||
def render_widget(self, widget: Widget, crop: Region) -> Lines:
|
||||
"""Render the content for a widget.
|
||||
|
||||
Args:
|
||||
widget (Widget): A widget.
|
||||
region (Region): A region of the widget to render.
|
||||
|
||||
Returns:
|
||||
Lines: Rendered lines.
|
||||
"""
|
||||
(base_background, base_color), (background, color) = widget.colors
|
||||
lines = self.render(
|
||||
widget.styles,
|
||||
widget.region.size,
|
||||
base_background,
|
||||
background,
|
||||
widget.render_line,
|
||||
crop=crop,
|
||||
)
|
||||
return lines
|
||||
|
||||
def render(
|
||||
self,
|
||||
styles: StylesBase,
|
||||
size: Size,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_line: RenderLineCallback,
|
||||
crop: Region | None = None,
|
||||
) -> Lines:
|
||||
"""Render a given region.
|
||||
|
||||
Args:
|
||||
region (Region): A region in the screen to render.
|
||||
|
||||
Returns:
|
||||
Lines: List of Segments, one per line.
|
||||
"""
|
||||
return self._render(
|
||||
size,
|
||||
size.region if crop is None else crop,
|
||||
styles,
|
||||
base_background,
|
||||
background,
|
||||
render_line,
|
||||
)
|
||||
|
||||
def _render(
|
||||
self,
|
||||
size: Size,
|
||||
crop: Region,
|
||||
styles: StylesBase,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
) -> Lines:
|
||||
width, height = size
|
||||
if width != self._width:
|
||||
self.clear()
|
||||
self._width = width
|
||||
lines: Lines = []
|
||||
add_line = lines.append
|
||||
simplify = Segment.simplify
|
||||
|
||||
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:
|
||||
line = render_line(
|
||||
styles, y, size, base_background, background, render_content_line
|
||||
)
|
||||
line = list(simplify(line))
|
||||
self._cache[y] = line
|
||||
else:
|
||||
line = self._cache[y]
|
||||
add_line(line)
|
||||
self._dirty_lines.difference_update(crop.line_range)
|
||||
|
||||
if crop.column_span != (0, width):
|
||||
_line_crop = line_crop
|
||||
x1, x2 = crop.column_span
|
||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
||||
|
||||
return lines
|
||||
|
||||
def render_line(
|
||||
self,
|
||||
styles: StylesBase,
|
||||
y: int,
|
||||
size: Size,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
) -> list[Segment]:
|
||||
"""Render a styled lines.
|
||||
|
||||
Args:
|
||||
styles (RenderStyles): Styles object.
|
||||
y (int): The y coordinate of the line (relative to top of widget)
|
||||
size (Size): Size of the widget.
|
||||
base_background (Color): The background color beneath this widget.
|
||||
background (Color): Background color of the widget.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A line as a list of segments.
|
||||
"""
|
||||
|
||||
gutter = styles.gutter
|
||||
width, height = size
|
||||
|
||||
pad_top, pad_right, pad_bottom, pad_left = styles.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 = Style.from_color
|
||||
|
||||
rich_style = styles.rich_style
|
||||
inner = from_color(bgcolor=background.rich_color) + rich_style
|
||||
outer = from_color(bgcolor=base_background.rich_color)
|
||||
|
||||
def post(segments: Iterable[Segment]) -> list[Segment]:
|
||||
"""Post process segments to apply opacity and tint.
|
||||
|
||||
Args:
|
||||
segments (Iterable[Segment]): Iterable of segments.
|
||||
|
||||
Returns:
|
||||
list[Segment]: New list of segments
|
||||
"""
|
||||
if styles.opacity != 1.0:
|
||||
segments = Opacity.process_segments(segments, styles.opacity)
|
||||
if styles.tint.a:
|
||||
segments = Tint.process_segments(segments, styles.tint)
|
||||
return segments if isinstance(segments, list) else list(segments)
|
||||
|
||||
line: Iterable[Segment]
|
||||
# Draw top or bottom borders (A)
|
||||
if (border_top and y == 0) or (border_bottom and y == height - 1):
|
||||
border_color = border_top_color if y == 0 else border_bottom_color
|
||||
box_segments = get_box(
|
||||
border_top if y == 0 else border_bottom,
|
||||
inner,
|
||||
outer,
|
||||
from_color(color=border_color.rich_color),
|
||||
)
|
||||
line = render_row(
|
||||
box_segments[0 if y == 0 else 2],
|
||||
width,
|
||||
border_left != "",
|
||||
border_right != "",
|
||||
)
|
||||
|
||||
# Draw padding (B)
|
||||
elif (pad_top and y < gutter.top) or (
|
||||
pad_bottom and y >= height - gutter.bottom
|
||||
):
|
||||
background_style = from_color(
|
||||
color=rich_style.color, bgcolor=background.rich_color
|
||||
)
|
||||
left_style = from_color(color=border_left_color.rich_color)
|
||||
left = get_box(border_left, inner, outer, left_style)[1][0]
|
||||
right_style = from_color(color=border_right_color.rich_color)
|
||||
right = get_box(border_right, inner, outer, right_style)[1][2]
|
||||
if border_left and border_right:
|
||||
line = [left, Segment(" " * (width - 2), background_style), right]
|
||||
elif border_left:
|
||||
line = [left, Segment(" " * (width - 1), background_style)]
|
||||
elif border_right:
|
||||
line = [Segment(" " * (width - 1), background_style), right]
|
||||
else:
|
||||
line = [Segment(" " * width, background_style)]
|
||||
else:
|
||||
# Content with border and padding (C)
|
||||
line = render_content_line(y - gutter.top)
|
||||
if inner:
|
||||
line = Segment.apply_style(line, inner)
|
||||
line = line_pad(line, pad_left, pad_right, inner)
|
||||
|
||||
if border_left or border_right:
|
||||
# Add left / right border
|
||||
left_style = from_color(border_left_color.rich_color)
|
||||
left = get_box(border_left, inner, outer, left_style)[1][0]
|
||||
right_style = from_color(border_right_color.rich_color)
|
||||
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,
|
||||
from_color(color=outline_color.rich_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 = from_color(outline_left_color.rich_color)
|
||||
left = get_box(outline_left, inner, outer, left_style)[1][0]
|
||||
right_style = from_color(outline_right_color.rich_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]
|
||||
|
||||
return post(line)
|
||||
@@ -1,260 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.style import Style
|
||||
from typing import Iterable, TYPE_CHECKING
|
||||
|
||||
from rich.segment import Segment
|
||||
|
||||
from ._border import get_box, render_row
|
||||
from .color import Color
|
||||
from .css.types import EdgeType
|
||||
from .renderables.opacity import Opacity
|
||||
from .renderables.tint import Tint
|
||||
from ._segment_tools import line_crop, line_pad, line_trim
|
||||
from ._types import Lines
|
||||
from .geometry import Region, Size
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .css.styles import RenderStyles
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
class StylesRenderer:
|
||||
"""Responsible for rendering CSS Styles and keeping a cached of rendered lines."""
|
||||
|
||||
def __init__(self, widget: Widget) -> None:
|
||||
self._widget = widget
|
||||
self._cache: dict[int, list[Segment]] = {}
|
||||
self._dirty_lines: set[int] = set()
|
||||
|
||||
def set_dirty(self, *regions: Region) -> None:
|
||||
"""Add a dirty region, or set the entire widget as dirty."""
|
||||
if regions:
|
||||
for region in regions:
|
||||
self._dirty_lines.update(region.line_range)
|
||||
else:
|
||||
self._dirty_lines.clear()
|
||||
self._dirty_lines.update(self._widget.size.lines_range)
|
||||
|
||||
def render(self, region: Region) -> Lines:
|
||||
|
||||
widget = self._widget
|
||||
styles = widget.styles
|
||||
size = widget.size
|
||||
(base_background, base_color), (background, color) = widget.colors
|
||||
|
||||
return self._render(size, region, styles, base_background, background)
|
||||
|
||||
def _render(
|
||||
self,
|
||||
size: Size,
|
||||
region: Region,
|
||||
styles: RenderStyles,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
):
|
||||
width, height = size
|
||||
lines: Lines = []
|
||||
add_line = lines.append
|
||||
simplify = Segment.simplify
|
||||
|
||||
is_dirty = self._dirty_lines.__contains__
|
||||
render_line = self.render_line
|
||||
for y in region.line_range:
|
||||
if is_dirty(y) or y not in self._cache:
|
||||
line = render_line(styles, y, size, base_background, background)
|
||||
line = list(simplify(line))
|
||||
self._cache[y] = line
|
||||
else:
|
||||
line = self._cache[y]
|
||||
add_line(line)
|
||||
self._dirty_lines.difference_update(region.line_range)
|
||||
|
||||
if region.column_span != (0, width):
|
||||
_line_crop = line_crop
|
||||
x1, x2 = region.column_span
|
||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
||||
|
||||
return lines
|
||||
|
||||
def render_content_line(self, y: int) -> list[Segment]:
|
||||
return self._widget.render_line(y)
|
||||
|
||||
def render_line(
|
||||
self,
|
||||
styles: RenderStyles,
|
||||
y: int,
|
||||
size: Size,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
) -> list[Segment]:
|
||||
|
||||
gutter = styles.gutter
|
||||
width, height = size
|
||||
|
||||
pad_top, pad_right, pad_bottom, pad_left = styles.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 = Style.from_color
|
||||
|
||||
rich_style = styles.rich_style
|
||||
inner_style = from_color(bgcolor=background.rich_color) + rich_style
|
||||
outer_style = from_color(bgcolor=base_background.rich_color)
|
||||
|
||||
def post(segments: Iterable[Segment]) -> list[Segment]:
|
||||
if styles.opacity != 1.0:
|
||||
segments = Opacity.process_segments(segments, styles.opacity)
|
||||
if styles.tint.a:
|
||||
segments = Tint.process_segments(segments, styles.tint)
|
||||
return segments if isinstance(segments, list) else list(segments)
|
||||
|
||||
line: Iterable[Segment]
|
||||
# Draw top or bottom borders
|
||||
if (border_top and y == 0) or (border_bottom and y == height - 1):
|
||||
|
||||
border_color = border_top_color if y == 0 else border_bottom_color
|
||||
box_segments = get_box(
|
||||
border_top if y == 0 else border_bottom,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(color=border_color.rich_color),
|
||||
)
|
||||
line = render_row(
|
||||
box_segments[0 if y == 0 else 2],
|
||||
width,
|
||||
border_left != "",
|
||||
border_right != "",
|
||||
)
|
||||
|
||||
# Draw padding
|
||||
elif (pad_top and y < gutter.top) or (
|
||||
pad_bottom and y >= height - gutter.bottom
|
||||
):
|
||||
background_style = from_color(
|
||||
color=rich_style.color, bgcolor=background.rich_color
|
||||
)
|
||||
_, (left, _, _), _ = get_box(
|
||||
border_left,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(color=border_left_color.rich_color),
|
||||
)
|
||||
_, (_, _, right), _ = get_box(
|
||||
border_right,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(color=border_right_color.rich_color),
|
||||
)
|
||||
if border_left and border_right:
|
||||
line = [left, Segment(" " * (width - 2), background_style), right]
|
||||
if border_left:
|
||||
line = [left, Segment(" " * (width - 1), background_style)]
|
||||
if border_right:
|
||||
line = [Segment(" " * (width - 1), background_style), right]
|
||||
else:
|
||||
line = [Segment(" " * width, background_style)]
|
||||
else:
|
||||
# Apply background style
|
||||
line = self.render_content_line(y - gutter.top)
|
||||
if inner_style:
|
||||
line = Segment.apply_style(line, inner_style)
|
||||
line = line_pad(line, pad_left, pad_right, inner_style)
|
||||
|
||||
if border_left or border_right:
|
||||
# Add left / right border
|
||||
_, (left, _, _), _ = get_box(
|
||||
border_left,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(border_left_color.rich_color),
|
||||
)
|
||||
_, (_, _, right), _ = get_box(
|
||||
border_right,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(border_right_color.rich_color),
|
||||
)
|
||||
|
||||
if border_left and border_right:
|
||||
line = [left, *line, right]
|
||||
elif border_left:
|
||||
line = [left, *line]
|
||||
else:
|
||||
line = [*line, right]
|
||||
|
||||
if (outline_top and y == 0) or (outline_bottom and y == height - 1):
|
||||
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_style,
|
||||
outer_style,
|
||||
from_color(color=outline_color.rich_color),
|
||||
)
|
||||
line = render_row(
|
||||
box_segments[0 if y == 0 else 2],
|
||||
width,
|
||||
outline_left != "",
|
||||
outline_right != "",
|
||||
)
|
||||
|
||||
elif outline_left or outline_right:
|
||||
_, (left, _, _), _ = get_box(
|
||||
outline_left,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(outline_left_color.rich_color),
|
||||
)
|
||||
_, (_, _, right), _ = get_box(
|
||||
outline_right,
|
||||
inner_style,
|
||||
outer_style,
|
||||
from_color(outline_right_color.rich_color),
|
||||
)
|
||||
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]
|
||||
|
||||
return post(line)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from rich import print
|
||||
from .css.styles import Styles
|
||||
|
||||
styles = Styles()
|
||||
styles.padding = 2
|
||||
styles.border = (
|
||||
("tall", Color.parse("red")),
|
||||
("none", Color.parse("white")),
|
||||
("outer", Color.parse("red")),
|
||||
("none", Color.parse("red")),
|
||||
)
|
||||
|
||||
size = Size(40, 10)
|
||||
sr = StylesRenderer(None)
|
||||
lines = sr._render(
|
||||
size, size.region, styles, Color.parse("blue"), Color.parse("green")
|
||||
)
|
||||
for line in lines:
|
||||
print(line)
|
||||
from rich.segment import SegmentLines
|
||||
|
||||
print(SegmentLines(lines, new_lines=True))
|
||||
@@ -6,7 +6,7 @@ exception should be when passing things to a Rich renderable, which will need to
|
||||
`rich_color` attribute to perform a conversion.
|
||||
|
||||
I'm not entirely happy with burdening the user with two similar color classes. In a future
|
||||
update we might add a protocol to convert automatically so the dev could use them interchangably.
|
||||
update we might add a protocol to convert automatically so the dev could use them interchangeably.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"}
|
||||
VALID_BORDER: Final[set[EdgeType]] = {
|
||||
"none",
|
||||
"hidden",
|
||||
"ascii",
|
||||
"round",
|
||||
"blank",
|
||||
"solid",
|
||||
|
||||
@@ -261,12 +261,6 @@ class StylesBase(ABC):
|
||||
spacing = self.padding + self.border.spacing
|
||||
return spacing
|
||||
|
||||
@property
|
||||
def content_gutter(self) -> Spacing:
|
||||
"""The spacing that surrounds the content area of the widget."""
|
||||
spacing = self.padding + self.border.spacing + self.margin
|
||||
return spacing
|
||||
|
||||
@property
|
||||
def auto_dimensions(self) -> bool:
|
||||
"""Check if width or height are set to 'auto'."""
|
||||
|
||||
@@ -140,7 +140,8 @@ class Size(NamedTuple):
|
||||
return Region(0, 0, width, height)
|
||||
|
||||
@property
|
||||
def lines_range(self) -> range:
|
||||
def line_range(self) -> range:
|
||||
"""Get a range covering lines."""
|
||||
return range(self.height)
|
||||
|
||||
def __add__(self, other: object) -> Size:
|
||||
|
||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from fractions import Fraction
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Collection,
|
||||
TYPE_CHECKING,
|
||||
Callable,
|
||||
Iterable,
|
||||
NamedTuple,
|
||||
)
|
||||
@@ -20,37 +20,32 @@ from rich.padding import Padding
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from . import errors
|
||||
from . import events
|
||||
from . import errors, events, messages
|
||||
from ._animator import BoundAnimator
|
||||
from ._border import Border
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from ._context import active_app
|
||||
from ._styles_render import StylesRenderer
|
||||
from ._layout import ArrangeResult, Layout
|
||||
from ._segment_tools import line_crop
|
||||
from ._styles_cache import StylesCache
|
||||
from ._types import Lines
|
||||
from .box_model import BoxModel, get_box_model
|
||||
from .dom import DOMNode
|
||||
from ._layout import ArrangeResult
|
||||
from .geometry import clamp, Offset, Region, Size, Spacing
|
||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||
from .layouts.vertical import VerticalLayout
|
||||
from .message import Message
|
||||
from . import messages
|
||||
from ._layout import Layout
|
||||
from .reactive import Reactive, watch
|
||||
from .renderables.opacity import Opacity
|
||||
from .renderables.tint import Tint
|
||||
from ._segment_tools import line_crop
|
||||
from .css.styles import Styles
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App, ComposeResult
|
||||
from .scrollbar import (
|
||||
ScrollBar,
|
||||
ScrollTo,
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
ScrollLeft,
|
||||
ScrollRight,
|
||||
ScrollTo,
|
||||
ScrollUp,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,7 +115,7 @@ class Widget(DOMNode):
|
||||
self._arrangement: ArrangeResult | None = None
|
||||
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
||||
|
||||
self._styles_renderer = StylesRenderer(self)
|
||||
self._styles_cache = StylesCache()
|
||||
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
@@ -443,9 +438,14 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
if regions:
|
||||
self._dirty_regions.update(regions)
|
||||
widget_regions = [
|
||||
region.translate(self.content_offset) for region in regions
|
||||
]
|
||||
self._dirty_regions.update(widget_regions)
|
||||
self._styles_cache.set_dirty(*widget_regions)
|
||||
else:
|
||||
self._dirty_regions.clear()
|
||||
self._styles_cache.clear()
|
||||
# TODO: Does this need to be content region?
|
||||
# self._dirty_regions.append(self.size.region)
|
||||
self._dirty_regions.add(self.size.region)
|
||||
@@ -668,7 +668,7 @@ class Widget(DOMNode):
|
||||
bool: True if the window was scrolled.
|
||||
"""
|
||||
|
||||
window = self.region.at_offset(self.scroll_offset)
|
||||
window = self.content_region.at_offset(self.scroll_offset)
|
||||
if spacing is not None:
|
||||
window = window.shrink(spacing)
|
||||
delta = Region.get_scroll_to_visible(window, region)
|
||||
@@ -847,7 +847,7 @@ class Widget(DOMNode):
|
||||
|
||||
@property
|
||||
def content_size(self) -> Size:
|
||||
return self._size - self.styles.gutter.totals
|
||||
return self.content_region.size
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
@@ -860,12 +860,12 @@ class Widget(DOMNode):
|
||||
@property
|
||||
def content_region(self) -> Region:
|
||||
"""Gets an absolute region containing the content (minus padding and border)."""
|
||||
return self.region.shrink(self.styles.content_gutter)
|
||||
return self.region.shrink(self.styles.gutter)
|
||||
|
||||
@property
|
||||
def content_offset(self) -> Offset:
|
||||
"""An offset from the Widget origin where the content begins."""
|
||||
x, y = self.styles.content_gutter.top_left
|
||||
x, y = self.styles.gutter.top_left
|
||||
return Offset(x, y)
|
||||
|
||||
@property
|
||||
@@ -998,15 +998,9 @@ class Widget(DOMNode):
|
||||
Lines: A list of list of segments
|
||||
"""
|
||||
if self._dirty_regions:
|
||||
self._styles_renderer.set_dirty(*self._dirty_regions)
|
||||
self._render_lines()
|
||||
|
||||
lines = self._styles_renderer.render(crop)
|
||||
return lines
|
||||
|
||||
x1, y1, x2, y2 = crop.corners
|
||||
lines = self._render_cache.lines[y1:y2]
|
||||
lines = self._crop_lines(lines, x1, x2)
|
||||
lines = self._styles_cache.render_widget(self, crop)
|
||||
return lines
|
||||
|
||||
def get_style_at(self, x: int, y: int) -> Style:
|
||||
|
||||
@@ -213,6 +213,7 @@ class DataTable(ScrollView, Generic[CellType]):
|
||||
self._row_render_cache.clear()
|
||||
self._cell_render_cache.clear()
|
||||
self._line_cache.clear()
|
||||
self._styles_cache.clear()
|
||||
|
||||
def get_row_height(self, row_index: int) -> int:
|
||||
if row_index == -1:
|
||||
@@ -264,7 +265,8 @@ class DataTable(ScrollView, Generic[CellType]):
|
||||
y = row.y
|
||||
if self.show_header:
|
||||
y += self.header_height
|
||||
return Region(x, y, width, height)
|
||||
cell_region = Region(x, y, width, height)
|
||||
return cell_region
|
||||
|
||||
def add_column(self, label: TextType, *, width: int = 10) -> None:
|
||||
"""Add a column to the table.
|
||||
@@ -460,7 +462,7 @@ class DataTable(ScrollView, Generic[CellType]):
|
||||
list[Segment]: List of segments for rendering.
|
||||
"""
|
||||
|
||||
width = self.region.width
|
||||
width = self.content_size.width
|
||||
|
||||
try:
|
||||
row_index, line_no = self._get_offsets(y)
|
||||
@@ -496,38 +498,58 @@ class DataTable(ScrollView, Generic[CellType]):
|
||||
self._line_cache[cache_key] = simplified_segments
|
||||
return segments
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
"""Render lines within a given region.
|
||||
|
||||
Args:
|
||||
crop (Region): Region to crop to.
|
||||
|
||||
Returns:
|
||||
Lines: A list of segments for every line within crop region.
|
||||
"""
|
||||
scroll_y = self.scroll_offset.y
|
||||
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
|
||||
|
||||
base_style = self.rich_style
|
||||
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
width, height = self.content_size
|
||||
scroll_x, scroll_y = self.scroll_offset
|
||||
fixed_top_row_count = sum(
|
||||
self.get_row_height(row_index) for row_index in range(self.fixed_rows)
|
||||
)
|
||||
if self.show_header:
|
||||
fixed_top_row_count += self.get_row_height(-1)
|
||||
|
||||
render_line = self._render_line
|
||||
fixed_lines = [
|
||||
render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count)
|
||||
]
|
||||
lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)]
|
||||
style = self.rich_style
|
||||
|
||||
for line_index, y in enumerate(range(y1, y2)):
|
||||
if y - scroll_y < fixed_top_row_count:
|
||||
lines[line_index] = fixed_lines[line_index]
|
||||
if y >= fixed_top_row_count:
|
||||
y += scroll_y
|
||||
|
||||
return self._render_line(y, scroll_x, scroll_x + width, style)
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
lines = self._styles_cache.render_widget(self, crop)
|
||||
return lines
|
||||
|
||||
# def render_lines(self, crop: Region) -> Lines:
|
||||
# """Render lines within a given region.
|
||||
|
||||
# Args:
|
||||
# crop (Region): Region to crop to.
|
||||
|
||||
# Returns:
|
||||
# Lines: A list of segments for every line within crop region.
|
||||
# """
|
||||
# scroll_y = self.scroll_offset.y
|
||||
# x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
|
||||
|
||||
# base_style = self.rich_style
|
||||
|
||||
# fixed_top_row_count = sum(
|
||||
# self.get_row_height(row_index) for row_index in range(self.fixed_rows)
|
||||
# )
|
||||
# if self.show_header:
|
||||
# fixed_top_row_count += self.get_row_height(-1)
|
||||
|
||||
# render_line = self._render_line
|
||||
# fixed_lines = [
|
||||
# render_line(y, x1, x2, base_style) for y in range(0, fixed_top_row_count)
|
||||
# ]
|
||||
# lines = [render_line(y, x1, x2, base_style) for y in range(y1, y2)]
|
||||
|
||||
# for line_index, y in enumerate(range(y1, y2)):
|
||||
# if y - scroll_y < fixed_top_row_count:
|
||||
# lines[line_index] = fixed_lines[line_index]
|
||||
|
||||
# return lines
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove):
|
||||
meta = event.style.meta
|
||||
if meta:
|
||||
@@ -551,6 +573,7 @@ class DataTable(ScrollView, Generic[CellType]):
|
||||
|
||||
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
|
||||
region = self._get_cell_region(self.cursor_row, self.cursor_column)
|
||||
region.translate(self.content_offset)
|
||||
spacing = self._get_cell_border()
|
||||
self.scroll_to_region(region, animate=animate, spacing=spacing)
|
||||
|
||||
|
||||
25
tests/test_border.py
Normal file
25
tests/test_border.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from textual._border import get_box, render_row
|
||||
|
||||
|
||||
def test_border_render_row():
|
||||
|
||||
style = Style.parse("red")
|
||||
row = (Segment("┏", style), Segment("━", style), Segment("┓", style))
|
||||
|
||||
assert render_row(row, 5, False, False) == [Segment(row[1].text * 5, row[1].style)]
|
||||
assert render_row(row, 5, True, False) == [
|
||||
row[0],
|
||||
Segment(row[1].text * 4, row[1].style),
|
||||
]
|
||||
assert render_row(row, 5, False, True) == [
|
||||
Segment(row[1].text * 4, row[1].style),
|
||||
row[2],
|
||||
]
|
||||
assert render_row(row, 5, True, True) == [
|
||||
row[0],
|
||||
Segment(row[1].text * 3, row[1].style),
|
||||
row[2],
|
||||
]
|
||||
@@ -2,7 +2,7 @@ from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
from textual._segment_tools import line_crop, line_trim
|
||||
from textual._segment_tools import line_crop, line_trim, line_pad
|
||||
|
||||
|
||||
def test_line_crop():
|
||||
@@ -89,3 +89,25 @@ def test_line_trim():
|
||||
]
|
||||
|
||||
assert line_trim([], True, True) == []
|
||||
|
||||
|
||||
def test_line_pad():
|
||||
segments = [Segment("foo"), Segment("bar")]
|
||||
style = Style.parse("red")
|
||||
assert line_pad(segments, 2, 3, style) == [
|
||||
Segment(" ", style),
|
||||
*segments,
|
||||
Segment(" ", style),
|
||||
]
|
||||
|
||||
assert line_pad(segments, 0, 3, style) == [
|
||||
*segments,
|
||||
Segment(" ", style),
|
||||
]
|
||||
|
||||
assert line_pad(segments, 2, 0, style) == [
|
||||
Segment(" ", style),
|
||||
*segments,
|
||||
]
|
||||
|
||||
assert line_pad(segments, 0, 0, style) == segments
|
||||
|
||||
260
tests/test_styles_cache.py
Normal file
260
tests/test_styles_cache.py
Normal file
@@ -0,0 +1,260 @@
|
||||
from rich.segment import Segment
|
||||
|
||||
from textual.color import Color
|
||||
from textual.geometry import Region, Size
|
||||
from textual.css.styles import Styles
|
||||
from textual._styles_cache import StylesCache
|
||||
from textual._types import Lines
|
||||
|
||||
|
||||
def _extract_content(lines: Lines):
|
||||
"""Extract the text content from lines."""
|
||||
content = ["".join(segment.text for segment in line) for line in lines]
|
||||
return content
|
||||
|
||||
|
||||
def test_set_dirty():
|
||||
cache = StylesCache()
|
||||
cache.set_dirty(Region(3, 4, 10, 2))
|
||||
assert not cache.is_dirty(3)
|
||||
assert cache.is_dirty(4)
|
||||
assert cache.is_dirty(5)
|
||||
assert not cache.is_dirty(6)
|
||||
|
||||
|
||||
def test_no_styles():
|
||||
"""Test that empty style returns the content un-altered"""
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(3, 3),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
)
|
||||
expected = [
|
||||
[Segment("foo", styles.rich_style)],
|
||||
[Segment("bar", styles.rich_style)],
|
||||
[Segment("baz", styles.rich_style)],
|
||||
]
|
||||
assert lines == expected
|
||||
|
||||
|
||||
def test_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
styles.border = ("heavy", "white")
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(5, 5),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
)
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
|
||||
expected_text = [
|
||||
"┏━━━┓",
|
||||
"┃foo┃",
|
||||
"┃bar┃",
|
||||
"┃baz┃",
|
||||
"┗━━━┛",
|
||||
]
|
||||
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_padding():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(5, 5),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
)
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
|
||||
expected_text = [
|
||||
" ",
|
||||
" foo ",
|
||||
" bar ",
|
||||
" baz ",
|
||||
" ",
|
||||
]
|
||||
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_padding_border():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
styles.border = ("heavy", "white")
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(7, 7),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
)
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
|
||||
expected_text = [
|
||||
"┏━━━━━┓",
|
||||
"┃ ┃",
|
||||
"┃ foo ┃",
|
||||
"┃ bar ┃",
|
||||
"┃ baz ┃",
|
||||
"┃ ┃",
|
||||
"┗━━━━━┛",
|
||||
]
|
||||
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_outline():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
styles.outline = ("heavy", "white")
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(3, 3),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
)
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
expected_text = [
|
||||
"┏━┓",
|
||||
"┃a┃",
|
||||
"┗━┛",
|
||||
]
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_crop():
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
styles.border = ("heavy", "white")
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(7, 7),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
content.__getitem__,
|
||||
crop=Region(2, 2, 3, 3),
|
||||
)
|
||||
text_content = _extract_content(lines)
|
||||
expected_text = [
|
||||
"foo",
|
||||
"bar",
|
||||
"baz",
|
||||
]
|
||||
assert text_content == expected_text
|
||||
|
||||
|
||||
def test_dirty_cache():
|
||||
"""Check that we only render content once or if it has been marked as dirty."""
|
||||
|
||||
content = [
|
||||
[Segment("foo")],
|
||||
[Segment("bar")],
|
||||
[Segment("baz")],
|
||||
]
|
||||
rendered_lines: list[int] = []
|
||||
|
||||
def get_content_line(y: int) -> list[Segment]:
|
||||
rendered_lines.append(y)
|
||||
return content[y]
|
||||
|
||||
styles = Styles()
|
||||
styles.padding = 1
|
||||
styles.border = ("heavy", "white")
|
||||
cache = StylesCache()
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(7, 7),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
get_content_line,
|
||||
)
|
||||
assert rendered_lines == [0, 1, 2]
|
||||
del rendered_lines[:]
|
||||
|
||||
text_content = _extract_content(lines)
|
||||
expected_text = [
|
||||
"┏━━━━━┓",
|
||||
"┃ ┃",
|
||||
"┃ foo ┃",
|
||||
"┃ bar ┃",
|
||||
"┃ baz ┃",
|
||||
"┃ ┃",
|
||||
"┗━━━━━┛",
|
||||
]
|
||||
assert text_content == expected_text
|
||||
|
||||
# Re-render styles, check that content was not requested
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(7, 7),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
get_content_line,
|
||||
)
|
||||
assert rendered_lines == []
|
||||
del rendered_lines[:]
|
||||
text_content = _extract_content(lines)
|
||||
assert text_content == expected_text
|
||||
|
||||
# Mark 2 lines as dirty
|
||||
cache.set_dirty(Region(0, 2, 7, 2))
|
||||
|
||||
lines = cache.render(
|
||||
styles,
|
||||
Size(7, 7),
|
||||
Color.parse("blue"),
|
||||
Color.parse("green"),
|
||||
get_content_line,
|
||||
)
|
||||
assert rendered_lines == [0, 1]
|
||||
text_content = _extract_content(lines)
|
||||
assert text_content == expected_text
|
||||
Reference in New Issue
Block a user