mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -14,8 +14,8 @@ class EasingApp(App):
|
||||
|
||||
def watch_side(self, side: bool) -> None:
|
||||
"""Animate when the side changes (False for left, True for right)."""
|
||||
width = self.easing_view.size.width
|
||||
animate_x = (width - self.placeholder.size.width) if side else 0
|
||||
width = self.easing_view.outer_size.width
|
||||
animate_x = (width - self.placeholder.outer_size.width) if side else 0
|
||||
self.placeholder.animate(
|
||||
"layout_offset_x", animate_x, easing=self.easing, duration=1
|
||||
)
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
scrollbar-size-vertical: 2;
|
||||
}
|
||||
|
||||
/* *:hover {
|
||||
tint: red 30%;
|
||||
} */
|
||||
*:hover {
|
||||
/* tint: 30% red;
|
||||
/* outline: heavy red; */
|
||||
}
|
||||
|
||||
App > Screen {
|
||||
layout: dock;
|
||||
@@ -27,9 +28,11 @@ App > Screen {
|
||||
}
|
||||
|
||||
DataTable {
|
||||
border: solid red;
|
||||
|
||||
margin: 1 1;
|
||||
/*border:heavy red;*/
|
||||
/* tint: 10% green; */
|
||||
/* opacity: 50%; */
|
||||
padding: 1;
|
||||
margin: 1 2;
|
||||
height: 12;
|
||||
}
|
||||
|
||||
@@ -104,7 +107,7 @@ Tweet {
|
||||
|
||||
|
||||
.scrollable {
|
||||
|
||||
|
||||
overflow-y: scroll;
|
||||
margin: 1 2;
|
||||
height: 20;
|
||||
@@ -112,8 +115,7 @@ Tweet {
|
||||
layout: vertical;
|
||||
}
|
||||
|
||||
.code {
|
||||
|
||||
.code {
|
||||
height: auto;
|
||||
|
||||
}
|
||||
@@ -224,13 +226,15 @@ Warning {
|
||||
|
||||
Success {
|
||||
width: 100%;
|
||||
height:3;
|
||||
width:90%;
|
||||
height:auto;
|
||||
box-sizing: border-box;
|
||||
background: $success-lighten-3;
|
||||
color: $text-success-lighten-3-fade-1;
|
||||
|
||||
border-top: hkey $success;
|
||||
border-bottom: hkey $success;
|
||||
margin: 1 2;
|
||||
|
||||
text-style: bold;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class Warning(Widget):
|
||||
|
||||
class Success(Widget):
|
||||
def render(self) -> Text:
|
||||
return Text("This is a success message", justify="center")
|
||||
return Text("This\nis\na\nsuccess\n message", justify="center")
|
||||
|
||||
|
||||
class BasicApp(App, css_path="basic.css"):
|
||||
|
||||
@@ -26,6 +26,7 @@ BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
|
||||
# - 2nd string represents (mid1, mid2, mid3)
|
||||
# - 3rd string represents (bottom1, bottom2, bottom3)
|
||||
"": (" ", " ", " "),
|
||||
"ascii": ("+-+", "| |", "+-+"),
|
||||
"none": (" ", " ", " "),
|
||||
"hidden": (" ", " ", " "),
|
||||
"blank": (" ", " ", " "),
|
||||
@@ -48,6 +49,7 @@ BORDER_LOCATIONS: dict[
|
||||
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
|
||||
] = {
|
||||
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||
"ascii": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||
"none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||
"hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||
"blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||
@@ -68,15 +70,19 @@ INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidde
|
||||
|
||||
BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]]
|
||||
|
||||
BoxSegments: TypeAlias = Tuple[
|
||||
Tuple[Segment, Segment, Segment],
|
||||
Tuple[Segment, Segment, Segment],
|
||||
Tuple[Segment, Segment, Segment],
|
||||
]
|
||||
|
||||
Borders: TypeAlias = Tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def get_box(
|
||||
name: EdgeType, inner_style: Style, outer_style: Style, style: Style
|
||||
) -> tuple[
|
||||
tuple[Segment, Segment, Segment],
|
||||
tuple[Segment, Segment, Segment],
|
||||
tuple[Segment, Segment, Segment],
|
||||
]:
|
||||
) -> BoxSegments:
|
||||
"""Get segments used to render a box.
|
||||
|
||||
Args:
|
||||
@@ -122,6 +128,31 @@ 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]
|
||||
if left:
|
||||
return [box1, Segment(box2.text * (width - 1), box2.style)]
|
||||
if right:
|
||||
return [Segment(box2.text * (width - 1), box2.style), box3]
|
||||
else:
|
||||
return [Segment(box2.text * width, box2.style)]
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Border:
|
||||
"""Renders Textual CSS borders.
|
||||
@@ -135,13 +166,13 @@ class Border:
|
||||
def __init__(
|
||||
self,
|
||||
renderable: RenderableType,
|
||||
edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle],
|
||||
borders: Borders,
|
||||
inner_color: Color,
|
||||
outer_color: Color,
|
||||
outline: bool = False,
|
||||
):
|
||||
self.renderable = renderable
|
||||
self.edge_styles = edge_styles
|
||||
self.edge_styles = borders
|
||||
self.outline = outline
|
||||
|
||||
(
|
||||
@@ -149,7 +180,7 @@ class Border:
|
||||
(right, right_color),
|
||||
(bottom, bottom_color),
|
||||
(left, left_color),
|
||||
) = edge_styles
|
||||
) = borders
|
||||
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
|
||||
self._sides = (top, right, bottom, left)
|
||||
from_color = Style.from_color
|
||||
|
||||
@@ -386,33 +386,23 @@ class Compositor:
|
||||
|
||||
# Add any scrollbars
|
||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||
container_size
|
||||
container_region
|
||||
):
|
||||
map[chrome_widget] = MapGeometry(
|
||||
chrome_region + container_region.offset + layout_offset,
|
||||
chrome_region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
container_size,
|
||||
container_size,
|
||||
)
|
||||
|
||||
if widget.is_container:
|
||||
# Add the container widget, which will render a background
|
||||
map[widget] = MapGeometry(
|
||||
region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
total_region.size,
|
||||
container_size,
|
||||
)
|
||||
else:
|
||||
map[widget] = MapGeometry(
|
||||
child_region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
child_region.size,
|
||||
container_size,
|
||||
)
|
||||
map[widget] = MapGeometry(
|
||||
region + layout_offset,
|
||||
order,
|
||||
clip,
|
||||
total_region.size,
|
||||
container_size,
|
||||
)
|
||||
|
||||
else:
|
||||
# Add the widget to the map
|
||||
@@ -657,13 +647,13 @@ class Compositor:
|
||||
for region, clip, lines in renders:
|
||||
render_region = intersection(region, clip)
|
||||
|
||||
for y, line in zip(render_region.y_range, lines):
|
||||
for y, line in zip(render_region.line_range, lines):
|
||||
if not is_rendered_line(y):
|
||||
continue
|
||||
|
||||
chops_line = chops[y]
|
||||
|
||||
first_cut, last_cut = render_region.x_extents
|
||||
first_cut, last_cut = render_region.column_span
|
||||
cuts_line = cuts[y]
|
||||
final_cuts = [
|
||||
cut for cut in cuts_line if (last_cut >= cut >= first_cut)
|
||||
|
||||
@@ -4,12 +4,17 @@ Tools for processing Segments, or lists of Segments.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._cells import cell_len
|
||||
|
||||
|
||||
def line_crop(segments: list[Segment], start: int, end: int, total: int):
|
||||
def line_crop(
|
||||
segments: list[Segment], start: int, end: int, total: int
|
||||
) -> list[Segment]:
|
||||
"""Crops a list of segments between two cell offsets.
|
||||
|
||||
Args:
|
||||
@@ -33,7 +38,7 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
|
||||
for segment in iter_segments:
|
||||
end_pos = pos + _cell_len(segment.text)
|
||||
if end_pos > start:
|
||||
segment = segment.split_cells(start - pos)[-1]
|
||||
segment = segment.split_cells(start - pos)[1]
|
||||
break
|
||||
pos = end_pos
|
||||
else:
|
||||
@@ -58,3 +63,64 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
|
||||
segment = next(iter_segments, None)
|
||||
|
||||
return output_segments
|
||||
|
||||
|
||||
def line_trim(segments: list[Segment], start: bool, end: bool) -> list[Segment]:
|
||||
"""Optionally remove a cell from the start and / or end of a list of segments.
|
||||
|
||||
Args:
|
||||
segments (list[Segment]): A line (list of Segments)
|
||||
start (bool): Remove cell from start.
|
||||
end (bool): Remove cell from end.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A new list of segments.
|
||||
"""
|
||||
segments = segments.copy()
|
||||
if segments and start:
|
||||
_, first_segment = segments[0].split_cells(1)
|
||||
if first_segment.text:
|
||||
segments[0] = first_segment
|
||||
else:
|
||||
segments.pop(0)
|
||||
if segments and end:
|
||||
last_segment = segments[-1]
|
||||
last_segment, _ = last_segment.split_cells(len(last_segment.text) - 1)
|
||||
if last_segment.text:
|
||||
segments[-1] = last_segment
|
||||
else:
|
||||
segments.pop()
|
||||
return segments
|
||||
|
||||
|
||||
def line_pad(
|
||||
segments: Iterable[Segment], pad_left: int, pad_right: int, style: Style
|
||||
) -> list[Segment]:
|
||||
"""Adds padding to the left and / or right of a list of segments.
|
||||
|
||||
Args:
|
||||
segments (Iterable[Segment]): A line of segments.
|
||||
pad_left (int): Cells to pad on the left.
|
||||
pad_right (int): Cells to pad on the right.
|
||||
style (Style): Style of padded cells.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A new line with padding.
|
||||
"""
|
||||
if pad_left and pad_right:
|
||||
return [
|
||||
Segment(" " * pad_left, style),
|
||||
*segments,
|
||||
Segment(" " * pad_right, style),
|
||||
]
|
||||
elif pad_left:
|
||||
return [
|
||||
Segment(" " * pad_left, style),
|
||||
*segments,
|
||||
]
|
||||
elif pad_right:
|
||||
return [
|
||||
*segments,
|
||||
Segment(" " * pad_right, style),
|
||||
]
|
||||
return list(segments)
|
||||
|
||||
339
src/textual/_styles_cache.py
Normal file
339
src/textual/_styles_cache.py
Normal file
@@ -0,0 +1,339 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Callable, Iterable, List
|
||||
|
||||
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, Spacing
|
||||
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.
|
||||
|
||||
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, 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:
|
||||
"""Check if a given line is dirty (needs to be rendered again).
|
||||
|
||||
Args:
|
||||
y (int): Y coordinate of line.
|
||||
|
||||
Returns:
|
||||
bool: 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) -> 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
|
||||
padding = widget.styles.padding + widget.scrollbar_gutter
|
||||
lines = self.render(
|
||||
widget.styles,
|
||||
widget.region.size,
|
||||
base_background,
|
||||
background,
|
||||
widget.render_line,
|
||||
content_size=widget.content_region.size,
|
||||
padding=padding,
|
||||
crop=crop,
|
||||
)
|
||||
return lines
|
||||
|
||||
def render(
|
||||
self,
|
||||
styles: StylesBase,
|
||||
size: Size,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
content_size: Size | None = None,
|
||||
padding: Spacing | None = None,
|
||||
crop: Region | None = None,
|
||||
) -> Lines:
|
||||
"""Render a widget content plus CSS styles.
|
||||
|
||||
Args:
|
||||
styles (StylesBase): CSS Styles object.
|
||||
size (Size): Size of widget.
|
||||
base_background (Color): Background color beneath widget.
|
||||
background (Color): Background color of widget.
|
||||
render_content_line (RenderLineCallback): Callback to render content line.
|
||||
content_size (Size | None, optional): Size of content or None to assume full size. Defaults to None.
|
||||
padding (Spacing | None, optional): Override padding from Styles, or None to use styles.padding. Defaults to None.
|
||||
crop (Region | None, optional): Region to crop to. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Lines: 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
|
||||
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,
|
||||
content_size,
|
||||
padding,
|
||||
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,
|
||||
content_size: Size,
|
||||
padding: Spacing,
|
||||
base_background: Color,
|
||||
background: Color,
|
||||
render_content_line: RenderLineCallback,
|
||||
) -> list[Segment]:
|
||||
"""Render a styled line.
|
||||
|
||||
Args:
|
||||
styles (StylesBase): Styles object.
|
||||
y (int): The y coordinate of the line (relative to widget screen offset).
|
||||
size (Size): Size of the widget.
|
||||
content_size (Size): Size of the content area.
|
||||
padding (Spacing): Padding.
|
||||
base_background (Color): Background color of widget beneath this line.
|
||||
background (Color): Background color of widget.
|
||||
render_content_line (RenderLineCallback): Callback to render a line of content.
|
||||
|
||||
Returns:
|
||||
list[Segment]: 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 = 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)
|
||||
content_y = y - gutter.top
|
||||
if content_y < content_height:
|
||||
line = render_content_line(y - gutter.top)
|
||||
else:
|
||||
line = [Segment(" " * content_width, inner)]
|
||||
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)
|
||||
@@ -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.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@@ -221,6 +221,8 @@ class BoxProperty:
|
||||
it's style. Example types are "rounded", "solid", and "dashed".
|
||||
"""
|
||||
box_type, color = obj.get_rule(self.name) or ("", self._default_color)
|
||||
if box_type in {"none", "hidden"}:
|
||||
box_type = ""
|
||||
return (box_type, color)
|
||||
|
||||
def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None):
|
||||
@@ -397,8 +399,8 @@ class BorderProperty:
|
||||
_border1, _border2, _border3, _border4 = (
|
||||
normalize_border_value(border[0]),
|
||||
normalize_border_value(border[1]),
|
||||
normalize_border_value(border[2]),
|
||||
normalize_border_value(border[3]),
|
||||
normalize_border_value(border[4]),
|
||||
)
|
||||
setattr(obj, top, _border1)
|
||||
setattr(obj, right, _border2)
|
||||
|
||||
@@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"}
|
||||
VALID_BORDER: Final[set[EdgeType]] = {
|
||||
"none",
|
||||
"hidden",
|
||||
"ascii",
|
||||
"round",
|
||||
"blank",
|
||||
"solid",
|
||||
|
||||
@@ -36,7 +36,7 @@ class ScalarAnimation(Animation):
|
||||
self.final_value = value
|
||||
self.easing = easing
|
||||
|
||||
size = widget.size
|
||||
size = widget.outer_size
|
||||
viewport = widget.app.size
|
||||
|
||||
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
|
||||
|
||||
@@ -168,6 +168,7 @@ class StylesBase(ABC):
|
||||
"max_height",
|
||||
"color",
|
||||
"background",
|
||||
"opacity",
|
||||
"tint",
|
||||
"scrollbar_color",
|
||||
"scrollbar_color_hover",
|
||||
@@ -261,12 +262,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'."""
|
||||
|
||||
@@ -14,6 +14,7 @@ else:
|
||||
Edge = Literal["top", "right", "bottom", "left"]
|
||||
EdgeType = Literal[
|
||||
"",
|
||||
"ascii",
|
||||
"none",
|
||||
"hidden",
|
||||
"blank",
|
||||
|
||||
@@ -139,6 +139,11 @@ class Size(NamedTuple):
|
||||
width, height = self
|
||||
return Region(0, 0, width, height)
|
||||
|
||||
@property
|
||||
def line_range(self) -> range:
|
||||
"""Get a range covering lines."""
|
||||
return range(self.height)
|
||||
|
||||
def __add__(self, other: object) -> Size:
|
||||
if isinstance(other, tuple):
|
||||
width, height = self
|
||||
@@ -306,21 +311,21 @@ class Region(NamedTuple):
|
||||
return bool(self.width and self.height)
|
||||
|
||||
@property
|
||||
def x_extents(self) -> tuple[int, int]:
|
||||
"""Get the starting and ending x coord.
|
||||
def column_span(self) -> tuple[int, int]:
|
||||
"""Get the start and end column (x coord).
|
||||
|
||||
The end value is non inclusive.
|
||||
The end value is exclusive.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: Pair of x coordinates (row numbers).
|
||||
tuple[int, int]: Pair of x coordinates (column numbers).
|
||||
"""
|
||||
return (self.x, self.x + self.width)
|
||||
|
||||
@property
|
||||
def y_extents(self) -> tuple[int, int]:
|
||||
"""Get the starting and ending x coord.
|
||||
def line_span(self) -> tuple[int, int]:
|
||||
"""Get the start and end line number (y coord).
|
||||
|
||||
The end value is non inclusive.
|
||||
The end value is exclusive.
|
||||
|
||||
Returns:
|
||||
tuple[int, int]: Pair of y coordinates (line numbers).
|
||||
@@ -381,12 +386,12 @@ class Region(NamedTuple):
|
||||
return x, y, x + width, y + height
|
||||
|
||||
@property
|
||||
def x_range(self) -> range:
|
||||
def column_range(self) -> range:
|
||||
"""A range object for X coordinates."""
|
||||
return range(self.x, self.x + self.width)
|
||||
|
||||
@property
|
||||
def y_range(self) -> range:
|
||||
def line_range(self) -> range:
|
||||
"""A range object for Y coordinates."""
|
||||
return range(self.y, self.y + self.height)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class HorizontalLayout(Layout):
|
||||
add_placement = placements.append
|
||||
|
||||
x = max_width = max_height = Fraction(0)
|
||||
parent_size = parent.size
|
||||
parent_size = parent.outer_size
|
||||
|
||||
children = list(parent.children)
|
||||
styles = [child.styles for child in children if child.styles.width is not None]
|
||||
|
||||
@@ -20,7 +20,7 @@ class VerticalLayout(Layout):
|
||||
placements: list[WidgetPlacement] = []
|
||||
add_placement = placements.append
|
||||
|
||||
parent_size = parent.size
|
||||
parent_size = parent.outer_size
|
||||
|
||||
children = list(parent.children)
|
||||
styles = [child.styles for child in children if child.styles.height is not None]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import functools
|
||||
from typing import Iterable
|
||||
|
||||
from rich.color import Color
|
||||
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
||||
@@ -8,51 +9,75 @@ from rich.style import Style
|
||||
from textual.renderables._blend_colors import blend_colors
|
||||
|
||||
|
||||
class Opacity:
|
||||
"""Wrap a renderable to blend foreground color into the background color.
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _get_blended_style_cached(
|
||||
bg_color: Color, fg_color: Color, opacity: float
|
||||
) -> Style:
|
||||
"""Blend from one color to another.
|
||||
|
||||
Cached because when a UI is static the opacity will be constant.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): The RenderableType to manipulate.
|
||||
opacity (float): The opacity as a float. A value of 1.0 means text is fully visible.
|
||||
bg_color (Color): Background color.
|
||||
fg_color (Color): Foreground color.
|
||||
opacity (float): Opacity.
|
||||
|
||||
Returns:
|
||||
Style: Resulting style.
|
||||
"""
|
||||
return Style.from_color(
|
||||
color=blend_colors(bg_color, fg_color, ratio=opacity),
|
||||
bgcolor=bg_color,
|
||||
)
|
||||
|
||||
|
||||
class Opacity:
|
||||
"""Blend foreground in to background."""
|
||||
|
||||
def __init__(self, renderable: RenderableType, opacity: float = 1.0) -> None:
|
||||
"""Wrap a renderable to blend foreground color into the background color.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): The RenderableType to manipulate.
|
||||
opacity (float): The opacity as a float. A value of 1.0 means text is fully visible.
|
||||
"""
|
||||
self.renderable = renderable
|
||||
self.opacity = opacity
|
||||
|
||||
@classmethod
|
||||
def process_segments(
|
||||
cls, segments: Iterable[Segment], opacity: float
|
||||
) -> Iterable[Segment]:
|
||||
"""Apply opacity to segments.
|
||||
|
||||
Args:
|
||||
segments (Iterable[Segment]): Incoming segments.
|
||||
opacity (float): Opacity to apply.
|
||||
|
||||
Returns:
|
||||
Iterable[Segment]: Segments with applied opacity.
|
||||
|
||||
"""
|
||||
_Segment = Segment
|
||||
for segment in segments:
|
||||
text, style, control = segment
|
||||
if not style:
|
||||
yield segment
|
||||
continue
|
||||
|
||||
color = style.color
|
||||
bgcolor = style.bgcolor
|
||||
if color and color.triplet and bgcolor and bgcolor.triplet:
|
||||
color_style = _get_blended_style_cached(bgcolor, color, opacity)
|
||||
yield _Segment(text, style + color_style)
|
||||
else:
|
||||
yield segment
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
segments = console.render(self.renderable, options)
|
||||
opacity = self.opacity
|
||||
for segment in segments:
|
||||
style = segment.style
|
||||
if not style:
|
||||
yield segment
|
||||
continue
|
||||
fg = style.color
|
||||
bg = style.bgcolor
|
||||
if fg and fg.triplet and bg and bg.triplet:
|
||||
color_style = _get_blended_style_cached(
|
||||
fg_color=fg, bg_color=bg, opacity=opacity
|
||||
)
|
||||
yield Segment(
|
||||
segment.text,
|
||||
style + color_style,
|
||||
segment.control,
|
||||
)
|
||||
else:
|
||||
yield segment
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1024)
|
||||
def _get_blended_style_cached(
|
||||
fg_color: Color, bg_color: Color, opacity: float
|
||||
) -> Style:
|
||||
return Style.from_color(
|
||||
color=blend_colors(bg_color, fg_color, ratio=opacity),
|
||||
bgcolor=bg_color,
|
||||
)
|
||||
return self.process_segments(segments, self.opacity)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
||||
from rich.segment import Segment
|
||||
@@ -12,7 +13,7 @@ class Tint:
|
||||
"""Applies a color on top of an existing renderable."""
|
||||
|
||||
def __init__(self, renderable: RenderableType, color: Color) -> None:
|
||||
"""_summary_
|
||||
"""Wrap a renderable to apply a tint color.
|
||||
|
||||
Args:
|
||||
renderable (RenderableType): A renderable.
|
||||
@@ -21,20 +22,29 @@ class Tint:
|
||||
self.renderable = renderable
|
||||
self.color = color
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
segments = console.render(self.renderable, options)
|
||||
@classmethod
|
||||
def process_segments(
|
||||
cls, segments: Iterable[Segment], color: Color
|
||||
) -> Iterable[Segment]:
|
||||
"""Apply tint to segments.
|
||||
|
||||
color = self.color
|
||||
Args:
|
||||
segments (Iterable[Segment]): Incoming segments.
|
||||
color (Color): Color of tint.
|
||||
|
||||
Returns:
|
||||
Iterable[Segment]: Segments with applied tint.
|
||||
|
||||
"""
|
||||
from_rich_color = Color.from_rich_color
|
||||
style_from_color = Style.from_color
|
||||
_Segment = Segment
|
||||
for segment in segments:
|
||||
text, style, control = segment
|
||||
if control or style is None:
|
||||
yield segment
|
||||
else:
|
||||
yield Segment(
|
||||
yield _Segment(
|
||||
text,
|
||||
(
|
||||
style
|
||||
@@ -45,3 +55,10 @@ class Tint:
|
||||
),
|
||||
control,
|
||||
)
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
segments = console.render(self.renderable, options)
|
||||
color = self.color
|
||||
return self.process_segments(segments, color)
|
||||
|
||||
@@ -131,7 +131,7 @@ class Screen(Widget):
|
||||
|
||||
def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
|
||||
"""Refresh the layout (can change size and positions of widgets)."""
|
||||
size = self.size if size is None else size
|
||||
size = self.outer_size if size is None else size
|
||||
if not size:
|
||||
return
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -17,38 +17,35 @@ from rich.align import Align
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.measure import Measurement
|
||||
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 ._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,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,6 +115,8 @@ class Widget(DOMNode):
|
||||
self._arrangement: ArrangeResult | None = None
|
||||
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
||||
|
||||
self._styles_cache = StylesCache()
|
||||
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
self.add_children(*children)
|
||||
|
||||
@@ -406,11 +405,6 @@ class Widget(DOMNode):
|
||||
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
||||
return enabled
|
||||
|
||||
@property
|
||||
def scrollbar_dimensions(self) -> tuple[int, int]:
|
||||
"""Get the size of any scrollbars on the widget"""
|
||||
return (self.scrollbar_size_horizontal, self.scrollbar_size_vertical)
|
||||
|
||||
@property
|
||||
def scrollbar_size_vertical(self) -> int:
|
||||
"""Get the width used by the *vertical* scrollbar."""
|
||||
@@ -427,6 +421,111 @@ class Widget(DOMNode):
|
||||
else 0
|
||||
)
|
||||
|
||||
@property
|
||||
def scrollbar_gutter(self) -> Spacing:
|
||||
gutter = Spacing(
|
||||
0, self.scrollbar_size_vertical, self.scrollbar_size_horizontal, 0
|
||||
)
|
||||
return gutter
|
||||
|
||||
@property
|
||||
def gutter(self) -> Spacing:
|
||||
"""Spacing for padding / border / scrollbars."""
|
||||
return self.styles.gutter + self.scrollbar_gutter
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
"""The size of the content area."""
|
||||
return self.content_region.size
|
||||
|
||||
@property
|
||||
def outer_size(self) -> Size:
|
||||
"""The size of the widget (including padding and border)."""
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def container_size(self) -> Size:
|
||||
"""The size of the container (parent widget)."""
|
||||
return self._container_size
|
||||
|
||||
@property
|
||||
def content_region(self) -> Region:
|
||||
"""Gets an absolute region containing the content (minus padding and border)."""
|
||||
content_region = self.region.shrink(self.gutter)
|
||||
return content_region
|
||||
|
||||
@property
|
||||
def content_offset(self) -> Offset:
|
||||
"""An offset from the Widget origin where the content begins."""
|
||||
x, y = self.gutter.top_left
|
||||
return Offset(x, y)
|
||||
|
||||
@property
|
||||
def region(self) -> Region:
|
||||
"""The region occupied by this widget, relative to the Screen."""
|
||||
try:
|
||||
return self.screen.find_widget(self).region
|
||||
except errors.NoWidget:
|
||||
return Region()
|
||||
|
||||
@property
|
||||
def window_region(self) -> Region:
|
||||
"""The region within the scrollable area that is currently visible.
|
||||
|
||||
Returns:
|
||||
Region: New region.
|
||||
"""
|
||||
window_region = self.region.at_offset(self.scroll_offset)
|
||||
return window_region
|
||||
|
||||
@property
|
||||
def scroll_offset(self) -> Offset:
|
||||
return Offset(int(self.scroll_x), int(self.scroll_y))
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
"""Check if the background styles is not set.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` if there is background color, otherwise ``False``.
|
||||
"""
|
||||
return self.is_scrollable and self.styles.background.is_transparent
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
"""Get the current console."""
|
||||
return active_app.get().console
|
||||
|
||||
@property
|
||||
def animate(self) -> BoundAnimator:
|
||||
if self._animate is None:
|
||||
self._animate = self.app.animator.bind(self)
|
||||
assert self._animate is not None
|
||||
return self._animate
|
||||
|
||||
@property
|
||||
def layout(self) -> Layout:
|
||||
"""Get the layout object if set in styles, or a default layout."""
|
||||
return self.styles.layout or self._default_layout
|
||||
|
||||
@property
|
||||
def is_container(self) -> bool:
|
||||
"""Check if this widget is a container (contains other widgets).
|
||||
|
||||
Returns:
|
||||
bool: True if this widget is a container.
|
||||
"""
|
||||
return self.styles.layout is not None or bool(self.children)
|
||||
|
||||
@property
|
||||
def is_scrollable(self) -> bool:
|
||||
"""Check if this Widget may be scrolled.
|
||||
|
||||
Returns:
|
||||
bool: True if this widget may be scrolled.
|
||||
"""
|
||||
return self.is_container
|
||||
|
||||
def _set_dirty(self, *regions: Region) -> None:
|
||||
"""Set the Widget as 'dirty' (requiring re-paint).
|
||||
|
||||
@@ -439,12 +538,14 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
if regions:
|
||||
self._dirty_regions.update(regions)
|
||||
content_offset = self.content_offset
|
||||
widget_regions = [region.translate(content_offset) for region in regions]
|
||||
self._dirty_regions.update(widget_regions)
|
||||
self._styles_cache.set_dirty(*widget_regions)
|
||||
else:
|
||||
self._dirty_regions.clear()
|
||||
# TODO: Does this need to be content region?
|
||||
# self._dirty_regions.append(self.size.region)
|
||||
self._dirty_regions.add(self.size.region)
|
||||
self._styles_cache.clear()
|
||||
self._dirty_regions.add(self.outer_size.region)
|
||||
|
||||
def get_dirty_regions(self) -> Collection[Region]:
|
||||
"""Get regions which require a repaint.
|
||||
@@ -664,7 +765,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)
|
||||
@@ -727,17 +828,17 @@ class Widget(DOMNode):
|
||||
region, _ = region.split_horizontal(-scrollbar_size_horizontal)
|
||||
return region
|
||||
|
||||
def _arrange_scrollbars(self, size: Size) -> Iterable[tuple[Widget, Region]]:
|
||||
def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]]:
|
||||
"""Arrange the 'chrome' widgets (typically scrollbars) for a layout element.
|
||||
|
||||
Args:
|
||||
size (Size): Size of the containing region.
|
||||
region (Region): The containing region.
|
||||
|
||||
Returns:
|
||||
Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region.
|
||||
|
||||
"""
|
||||
region = size.region
|
||||
|
||||
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
||||
|
||||
scrollbar_size_horizontal = self.scrollbar_size_horizontal
|
||||
@@ -833,94 +934,14 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
renderable = self.render()
|
||||
renderable = self._style_renderable(renderable)
|
||||
styles = self.styles
|
||||
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
|
||||
if content_align != ("left", "top"):
|
||||
horizontal, vertical = content_align
|
||||
renderable = Align(renderable, horizontal, vertical=vertical)
|
||||
|
||||
return renderable
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def container_size(self) -> Size:
|
||||
return self._container_size
|
||||
|
||||
@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)
|
||||
|
||||
@property
|
||||
def content_offset(self) -> Offset:
|
||||
"""An offset from the Widget origin where the content begins."""
|
||||
x, y = self.styles.content_gutter.top_left
|
||||
return Offset(x, y)
|
||||
|
||||
@property
|
||||
def region(self) -> Region:
|
||||
"""The region occupied by this widget, relative to the Screen."""
|
||||
try:
|
||||
return self.screen.find_widget(self).region
|
||||
except errors.NoWidget:
|
||||
return Region()
|
||||
|
||||
@property
|
||||
def window_region(self) -> Region:
|
||||
"""The region within the scrollable area that is currently visible.
|
||||
|
||||
Returns:
|
||||
Region: New region.
|
||||
"""
|
||||
window_region = self.region.at_offset(self.scroll_offset)
|
||||
return window_region
|
||||
|
||||
@property
|
||||
def scroll_offset(self) -> Offset:
|
||||
return Offset(int(self.scroll_x), int(self.scroll_y))
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
"""Check if the background styles is not set.
|
||||
|
||||
Returns:
|
||||
bool: ``True`` if there is background color, otherwise ``False``.
|
||||
"""
|
||||
return self.is_scrollable and self.styles.background.is_transparent
|
||||
|
||||
@property
|
||||
def console(self) -> Console:
|
||||
"""Get the current console."""
|
||||
return active_app.get().console
|
||||
|
||||
@property
|
||||
def animate(self) -> BoundAnimator:
|
||||
if self._animate is None:
|
||||
self._animate = self.app.animator.bind(self)
|
||||
assert self._animate is not None
|
||||
return self._animate
|
||||
|
||||
@property
|
||||
def layout(self) -> Layout:
|
||||
"""Get the layout object if set in styles, or a default layout."""
|
||||
return self.styles.layout or self._default_layout
|
||||
|
||||
@property
|
||||
def is_container(self) -> bool:
|
||||
"""Check if this widget is a container (contains other widgets).
|
||||
|
||||
Returns:
|
||||
bool: True if this widget is a container.
|
||||
"""
|
||||
return self.styles.layout is not None or bool(self.children)
|
||||
|
||||
@property
|
||||
def is_scrollable(self) -> bool:
|
||||
"""Check if this Widget may be scrolled.
|
||||
|
||||
Returns:
|
||||
bool: True if this widget may be scrolled.
|
||||
"""
|
||||
return self.is_container
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
self.app.update_styles()
|
||||
@@ -953,23 +974,30 @@ class Widget(DOMNode):
|
||||
else:
|
||||
self.refresh()
|
||||
|
||||
def _render_lines(self) -> None:
|
||||
def _render_content(self) -> None:
|
||||
"""Render all lines."""
|
||||
width, height = self.size
|
||||
renderable = self.render_styled()
|
||||
options = self.console.options.update_dimensions(width, height).update(
|
||||
highlight=False
|
||||
)
|
||||
lines = self.console.render_lines(renderable, options)
|
||||
lines = self.console.render_lines(renderable, options, style=self.rich_style)
|
||||
self._render_cache = RenderCache(self.size, lines)
|
||||
self._dirty_regions.clear()
|
||||
|
||||
def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
|
||||
width = self.size.width
|
||||
if (x1, x2) != (0, width):
|
||||
_line_crop = line_crop
|
||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
||||
return lines
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
"""Render a line of content.
|
||||
|
||||
Args:
|
||||
y (int): Y Coordinate of line.
|
||||
|
||||
Returns:
|
||||
list[Segment]: A rendered line.
|
||||
"""
|
||||
if self._dirty_regions:
|
||||
self._render_content()
|
||||
line = self._render_cache.lines[y]
|
||||
return line
|
||||
|
||||
def render_lines(self, crop: Region) -> Lines:
|
||||
"""Render the widget in to lines.
|
||||
@@ -980,12 +1008,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
Lines: A list of list of segments
|
||||
"""
|
||||
if self._dirty_regions:
|
||||
self._render_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.size.width
|
||||
|
||||
try:
|
||||
row_index, line_no = self._get_offsets(y)
|
||||
@@ -496,36 +498,40 @@ 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.
|
||||
def render_line(self, y: int) -> list[Segment]:
|
||||
"""Render a line of content.
|
||||
|
||||
Args:
|
||||
crop (Region): Region to crop to.
|
||||
y (int): Y Coordinate of line.
|
||||
|
||||
Returns:
|
||||
Lines: A list of segments for every line within crop region.
|
||||
list[Segment]: A rendered line.
|
||||
"""
|
||||
scroll_y = self.scroll_offset.y
|
||||
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
|
||||
|
||||
base_style = self.rich_style
|
||||
|
||||
width, height = self.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:
|
||||
"""Render the widget in to lines.
|
||||
|
||||
Args:
|
||||
crop (Region): Region within visible area to.
|
||||
|
||||
Returns:
|
||||
Lines: A list of list of segments
|
||||
"""
|
||||
lines = self._styles_cache.render_widget(self, crop)
|
||||
return lines
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove):
|
||||
|
||||
@@ -277,5 +277,5 @@ async def test_scrollbar_gutter(
|
||||
app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
|
||||
await app.boot_and_shutdown()
|
||||
|
||||
assert text_widget.size.width == expected_text_widget_width
|
||||
assert text_widget.outer_size.width == expected_text_widget_width
|
||||
assert container.scrollbars_enabled[0] is expects_vertical_scrollbar
|
||||
|
||||
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],
|
||||
]
|
||||
@@ -279,11 +279,11 @@ def test_size_sub():
|
||||
|
||||
|
||||
def test_region_x_extents():
|
||||
assert Region(5, 10, 20, 30).x_extents == (5, 25)
|
||||
assert Region(5, 10, 20, 30).column_span == (5, 25)
|
||||
|
||||
|
||||
def test_region_y_extents():
|
||||
assert Region(5, 10, 20, 30).y_extents == (10, 40)
|
||||
assert Region(5, 10, 20, 30).line_span == (10, 40)
|
||||
|
||||
|
||||
def test_region_x_max():
|
||||
@@ -294,12 +294,12 @@ def test_region_y_max():
|
||||
assert Region(5, 10, 20, 30).bottom == 40
|
||||
|
||||
|
||||
def test_region_x_range():
|
||||
assert Region(5, 10, 20, 30).x_range == range(5, 25)
|
||||
def test_region_columns_range():
|
||||
assert Region(5, 10, 20, 30).column_range == range(5, 25)
|
||||
|
||||
|
||||
def test_region_y_range():
|
||||
assert Region(5, 10, 20, 30).y_range == range(10, 40)
|
||||
def test_region_lines_range():
|
||||
assert Region(5, 10, 20, 30).line_range == range(10, 40)
|
||||
|
||||
|
||||
def test_region_reset_offset():
|
||||
|
||||
@@ -144,7 +144,7 @@ async def test_composition_of_vertical_container_with_children(
|
||||
async with app.in_running_state():
|
||||
# root widget checks:
|
||||
root_widget = cast(Widget, app.get_child("root"))
|
||||
assert root_widget.size == expected_screen_size
|
||||
assert root_widget.outer_size == expected_screen_size
|
||||
root_widget_region = app.screen.find_widget(root_widget).region
|
||||
assert root_widget_region == (
|
||||
0,
|
||||
@@ -158,7 +158,7 @@ async def test_composition_of_vertical_container_with_children(
|
||||
|
||||
# placeholder widgets checks:
|
||||
for placeholder in app_placeholders:
|
||||
assert placeholder.size == expected_placeholders_size
|
||||
assert placeholder.outer_size == expected_placeholders_size
|
||||
assert placeholder.styles.offset.x.value == 0.0
|
||||
assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x
|
||||
|
||||
@@ -224,7 +224,7 @@ async def test_border_edge_types_impact_on_widget_size(
|
||||
)
|
||||
assert box_inner_size == expected_box_inner_size
|
||||
|
||||
assert border_target.size == expected_box_size
|
||||
assert border_target.outer_size == expected_box_size
|
||||
|
||||
top_left_edge_style = app.screen.get_style_at(0, 0)
|
||||
top_left_edge_color = top_left_edge_style.color.name
|
||||
|
||||
@@ -2,7 +2,7 @@ from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
|
||||
from textual._segment_tools import line_crop
|
||||
from textual._segment_tools import line_crop, line_trim, line_pad
|
||||
|
||||
|
||||
def test_line_crop():
|
||||
@@ -62,3 +62,52 @@ def test_line_crop_edge_2():
|
||||
expected = []
|
||||
print(repr(result))
|
||||
assert result == expected
|
||||
|
||||
|
||||
def test_line_trim():
|
||||
segments = [Segment("foo")]
|
||||
|
||||
assert line_trim(segments, False, False) == segments
|
||||
assert line_trim(segments, True, False) == [Segment("oo")]
|
||||
assert line_trim(segments, False, True) == [Segment("fo")]
|
||||
assert line_trim(segments, True, True) == [Segment("o")]
|
||||
|
||||
fob_segments = [Segment("f"), Segment("o"), Segment("b")]
|
||||
|
||||
assert line_trim(fob_segments, True, False) == [
|
||||
Segment("o"),
|
||||
Segment("b"),
|
||||
]
|
||||
|
||||
assert line_trim(fob_segments, False, True) == [
|
||||
Segment("f"),
|
||||
Segment("o"),
|
||||
]
|
||||
|
||||
assert line_trim(fob_segments, True, True) == [
|
||||
Segment("o"),
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
270
tests/test_styles_cache.py
Normal file
270
tests/test_styles_cache.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
|
||||
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__,
|
||||
content_size=Size(3, 3),
|
||||
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,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
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,
|
||||
content_size=Size(3, 3),
|
||||
)
|
||||
assert rendered_lines == [0, 1]
|
||||
text_content = _extract_content(lines)
|
||||
assert text_content == expected_text
|
||||
@@ -159,7 +159,7 @@ class AppTest(App):
|
||||
|
||||
# We artificially tell the Compositor that the whole area should be refreshed
|
||||
screen._compositor._dirty_regions = {
|
||||
Region(0, 0, screen.size.width, screen.size.height),
|
||||
Region(0, 0, screen.outer_size.width, screen.outer_size.height),
|
||||
}
|
||||
screen.refresh(repaint=repaint, layout=layout)
|
||||
# We also have to make sure we have at least one dirty widget, or `screen._on_update()` will early return:
|
||||
|
||||
Reference in New Issue
Block a user