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:
|
def watch_side(self, side: bool) -> None:
|
||||||
"""Animate when the side changes (False for left, True for right)."""
|
"""Animate when the side changes (False for left, True for right)."""
|
||||||
width = self.easing_view.size.width
|
width = self.easing_view.outer_size.width
|
||||||
animate_x = (width - self.placeholder.size.width) if side else 0
|
animate_x = (width - self.placeholder.outer_size.width) if side else 0
|
||||||
self.placeholder.animate(
|
self.placeholder.animate(
|
||||||
"layout_offset_x", animate_x, easing=self.easing, duration=1
|
"layout_offset_x", animate_x, easing=self.easing, duration=1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,9 +15,10 @@
|
|||||||
scrollbar-size-vertical: 2;
|
scrollbar-size-vertical: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* *:hover {
|
*:hover {
|
||||||
tint: red 30%;
|
/* tint: 30% red;
|
||||||
} */
|
/* outline: heavy red; */
|
||||||
|
}
|
||||||
|
|
||||||
App > Screen {
|
App > Screen {
|
||||||
layout: dock;
|
layout: dock;
|
||||||
@@ -27,9 +28,11 @@ App > Screen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DataTable {
|
DataTable {
|
||||||
border: solid red;
|
/*border:heavy red;*/
|
||||||
|
/* tint: 10% green; */
|
||||||
margin: 1 1;
|
/* opacity: 50%; */
|
||||||
|
padding: 1;
|
||||||
|
margin: 1 2;
|
||||||
height: 12;
|
height: 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +107,7 @@ Tweet {
|
|||||||
|
|
||||||
|
|
||||||
.scrollable {
|
.scrollable {
|
||||||
|
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
margin: 1 2;
|
margin: 1 2;
|
||||||
height: 20;
|
height: 20;
|
||||||
@@ -112,8 +115,7 @@ Tweet {
|
|||||||
layout: vertical;
|
layout: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code {
|
.code {
|
||||||
|
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -224,13 +226,15 @@ Warning {
|
|||||||
|
|
||||||
Success {
|
Success {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height:3;
|
width:90%;
|
||||||
|
height:auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: $success-lighten-3;
|
background: $success-lighten-3;
|
||||||
color: $text-success-lighten-3-fade-1;
|
color: $text-success-lighten-3-fade-1;
|
||||||
|
|
||||||
border-top: hkey $success;
|
border-top: hkey $success;
|
||||||
border-bottom: hkey $success;
|
border-bottom: hkey $success;
|
||||||
margin: 1 2;
|
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
align-horizontal: center;
|
align-horizontal: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class Warning(Widget):
|
|||||||
|
|
||||||
class Success(Widget):
|
class Success(Widget):
|
||||||
def render(self) -> Text:
|
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"):
|
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)
|
# - 2nd string represents (mid1, mid2, mid3)
|
||||||
# - 3rd string represents (bottom1, bottom2, bottom3)
|
# - 3rd string represents (bottom1, bottom2, bottom3)
|
||||||
"": (" ", " ", " "),
|
"": (" ", " ", " "),
|
||||||
|
"ascii": ("+-+", "| |", "+-+"),
|
||||||
"none": (" ", " ", " "),
|
"none": (" ", " ", " "),
|
||||||
"hidden": (" ", " ", " "),
|
"hidden": (" ", " ", " "),
|
||||||
"blank": (" ", " ", " "),
|
"blank": (" ", " ", " "),
|
||||||
@@ -48,6 +49,7 @@ BORDER_LOCATIONS: dict[
|
|||||||
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
|
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
|
||||||
] = {
|
] = {
|
||||||
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
"": ((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)),
|
"none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
|
||||||
"hidden": ((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)),
|
"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]]
|
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)
|
@lru_cache(maxsize=1024)
|
||||||
def get_box(
|
def get_box(
|
||||||
name: EdgeType, inner_style: Style, outer_style: Style, style: Style
|
name: EdgeType, inner_style: Style, outer_style: Style, style: Style
|
||||||
) -> tuple[
|
) -> BoxSegments:
|
||||||
tuple[Segment, Segment, Segment],
|
|
||||||
tuple[Segment, Segment, Segment],
|
|
||||||
tuple[Segment, Segment, Segment],
|
|
||||||
]:
|
|
||||||
"""Get segments used to render a box.
|
"""Get segments used to render a box.
|
||||||
|
|
||||||
Args:
|
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
|
@rich.repr.auto
|
||||||
class Border:
|
class Border:
|
||||||
"""Renders Textual CSS borders.
|
"""Renders Textual CSS borders.
|
||||||
@@ -135,13 +166,13 @@ class Border:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
renderable: RenderableType,
|
renderable: RenderableType,
|
||||||
edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle],
|
borders: Borders,
|
||||||
inner_color: Color,
|
inner_color: Color,
|
||||||
outer_color: Color,
|
outer_color: Color,
|
||||||
outline: bool = False,
|
outline: bool = False,
|
||||||
):
|
):
|
||||||
self.renderable = renderable
|
self.renderable = renderable
|
||||||
self.edge_styles = edge_styles
|
self.edge_styles = borders
|
||||||
self.outline = outline
|
self.outline = outline
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -149,7 +180,7 @@ class Border:
|
|||||||
(right, right_color),
|
(right, right_color),
|
||||||
(bottom, bottom_color),
|
(bottom, bottom_color),
|
||||||
(left, left_color),
|
(left, left_color),
|
||||||
) = edge_styles
|
) = borders
|
||||||
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
|
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType]
|
||||||
self._sides = (top, right, bottom, left)
|
self._sides = (top, right, bottom, left)
|
||||||
from_color = Style.from_color
|
from_color = Style.from_color
|
||||||
|
|||||||
@@ -386,33 +386,23 @@ class Compositor:
|
|||||||
|
|
||||||
# Add any scrollbars
|
# Add any scrollbars
|
||||||
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
for chrome_widget, chrome_region in widget._arrange_scrollbars(
|
||||||
container_size
|
container_region
|
||||||
):
|
):
|
||||||
map[chrome_widget] = MapGeometry(
|
map[chrome_widget] = MapGeometry(
|
||||||
chrome_region + container_region.offset + layout_offset,
|
chrome_region + layout_offset,
|
||||||
order,
|
order,
|
||||||
clip,
|
clip,
|
||||||
container_size,
|
container_size,
|
||||||
container_size,
|
container_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
if widget.is_container:
|
map[widget] = MapGeometry(
|
||||||
# Add the container widget, which will render a background
|
region + layout_offset,
|
||||||
map[widget] = MapGeometry(
|
order,
|
||||||
region + layout_offset,
|
clip,
|
||||||
order,
|
total_region.size,
|
||||||
clip,
|
container_size,
|
||||||
total_region.size,
|
)
|
||||||
container_size,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
map[widget] = MapGeometry(
|
|
||||||
child_region + layout_offset,
|
|
||||||
order,
|
|
||||||
clip,
|
|
||||||
child_region.size,
|
|
||||||
container_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Add the widget to the map
|
# Add the widget to the map
|
||||||
@@ -657,13 +647,13 @@ class Compositor:
|
|||||||
for region, clip, lines in renders:
|
for region, clip, lines in renders:
|
||||||
render_region = intersection(region, clip)
|
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):
|
if not is_rendered_line(y):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
chops_line = chops[y]
|
chops_line = chops[y]
|
||||||
|
|
||||||
first_cut, last_cut = render_region.x_extents
|
first_cut, last_cut = render_region.column_span
|
||||||
cuts_line = cuts[y]
|
cuts_line = cuts[y]
|
||||||
final_cuts = [
|
final_cuts = [
|
||||||
cut for cut in cuts_line if (last_cut >= cut >= first_cut)
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
from ._cells import cell_len
|
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.
|
"""Crops a list of segments between two cell offsets.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -33,7 +38,7 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
|
|||||||
for segment in iter_segments:
|
for segment in iter_segments:
|
||||||
end_pos = pos + _cell_len(segment.text)
|
end_pos = pos + _cell_len(segment.text)
|
||||||
if end_pos > start:
|
if end_pos > start:
|
||||||
segment = segment.split_cells(start - pos)[-1]
|
segment = segment.split_cells(start - pos)[1]
|
||||||
break
|
break
|
||||||
pos = end_pos
|
pos = end_pos
|
||||||
else:
|
else:
|
||||||
@@ -58,3 +63,64 @@ def line_crop(segments: list[Segment], start: int, end: int, total: int):
|
|||||||
segment = next(iter_segments, None)
|
segment = next(iter_segments, None)
|
||||||
|
|
||||||
return output_segments
|
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.
|
`rich_color` attribute to perform a conversion.
|
||||||
|
|
||||||
I'm not entirely happy with burdening the user with two similar color classes. In a future
|
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".
|
it's style. Example types are "rounded", "solid", and "dashed".
|
||||||
"""
|
"""
|
||||||
box_type, color = obj.get_rule(self.name) or ("", self._default_color)
|
box_type, color = obj.get_rule(self.name) or ("", self._default_color)
|
||||||
|
if box_type in {"none", "hidden"}:
|
||||||
|
box_type = ""
|
||||||
return (box_type, color)
|
return (box_type, color)
|
||||||
|
|
||||||
def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None):
|
def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None):
|
||||||
@@ -397,8 +399,8 @@ class BorderProperty:
|
|||||||
_border1, _border2, _border3, _border4 = (
|
_border1, _border2, _border3, _border4 = (
|
||||||
normalize_border_value(border[0]),
|
normalize_border_value(border[0]),
|
||||||
normalize_border_value(border[1]),
|
normalize_border_value(border[1]),
|
||||||
|
normalize_border_value(border[2]),
|
||||||
normalize_border_value(border[3]),
|
normalize_border_value(border[3]),
|
||||||
normalize_border_value(border[4]),
|
|
||||||
)
|
)
|
||||||
setattr(obj, top, _border1)
|
setattr(obj, top, _border1)
|
||||||
setattr(obj, right, _border2)
|
setattr(obj, right, _border2)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ VALID_DISPLAY: Final = {"block", "none"}
|
|||||||
VALID_BORDER: Final[set[EdgeType]] = {
|
VALID_BORDER: Final[set[EdgeType]] = {
|
||||||
"none",
|
"none",
|
||||||
"hidden",
|
"hidden",
|
||||||
|
"ascii",
|
||||||
"round",
|
"round",
|
||||||
"blank",
|
"blank",
|
||||||
"solid",
|
"solid",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ScalarAnimation(Animation):
|
|||||||
self.final_value = value
|
self.final_value = value
|
||||||
self.easing = easing
|
self.easing = easing
|
||||||
|
|
||||||
size = widget.size
|
size = widget.outer_size
|
||||||
viewport = widget.app.size
|
viewport = widget.app.size
|
||||||
|
|
||||||
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
|
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ class StylesBase(ABC):
|
|||||||
"max_height",
|
"max_height",
|
||||||
"color",
|
"color",
|
||||||
"background",
|
"background",
|
||||||
|
"opacity",
|
||||||
"tint",
|
"tint",
|
||||||
"scrollbar_color",
|
"scrollbar_color",
|
||||||
"scrollbar_color_hover",
|
"scrollbar_color_hover",
|
||||||
@@ -261,12 +262,6 @@ class StylesBase(ABC):
|
|||||||
spacing = self.padding + self.border.spacing
|
spacing = self.padding + self.border.spacing
|
||||||
return 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
|
@property
|
||||||
def auto_dimensions(self) -> bool:
|
def auto_dimensions(self) -> bool:
|
||||||
"""Check if width or height are set to 'auto'."""
|
"""Check if width or height are set to 'auto'."""
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ else:
|
|||||||
Edge = Literal["top", "right", "bottom", "left"]
|
Edge = Literal["top", "right", "bottom", "left"]
|
||||||
EdgeType = Literal[
|
EdgeType = Literal[
|
||||||
"",
|
"",
|
||||||
|
"ascii",
|
||||||
"none",
|
"none",
|
||||||
"hidden",
|
"hidden",
|
||||||
"blank",
|
"blank",
|
||||||
|
|||||||
@@ -139,6 +139,11 @@ class Size(NamedTuple):
|
|||||||
width, height = self
|
width, height = self
|
||||||
return Region(0, 0, width, height)
|
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:
|
def __add__(self, other: object) -> Size:
|
||||||
if isinstance(other, tuple):
|
if isinstance(other, tuple):
|
||||||
width, height = self
|
width, height = self
|
||||||
@@ -306,21 +311,21 @@ class Region(NamedTuple):
|
|||||||
return bool(self.width and self.height)
|
return bool(self.width and self.height)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def x_extents(self) -> tuple[int, int]:
|
def column_span(self) -> tuple[int, int]:
|
||||||
"""Get the starting and ending x coord.
|
"""Get the start and end column (x coord).
|
||||||
|
|
||||||
The end value is non inclusive.
|
The end value is exclusive.
|
||||||
|
|
||||||
Returns:
|
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)
|
return (self.x, self.x + self.width)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def y_extents(self) -> tuple[int, int]:
|
def line_span(self) -> tuple[int, int]:
|
||||||
"""Get the starting and ending x coord.
|
"""Get the start and end line number (y coord).
|
||||||
|
|
||||||
The end value is non inclusive.
|
The end value is exclusive.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[int, int]: Pair of y coordinates (line numbers).
|
tuple[int, int]: Pair of y coordinates (line numbers).
|
||||||
@@ -381,12 +386,12 @@ class Region(NamedTuple):
|
|||||||
return x, y, x + width, y + height
|
return x, y, x + width, y + height
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def x_range(self) -> range:
|
def column_range(self) -> range:
|
||||||
"""A range object for X coordinates."""
|
"""A range object for X coordinates."""
|
||||||
return range(self.x, self.x + self.width)
|
return range(self.x, self.x + self.width)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def y_range(self) -> range:
|
def line_range(self) -> range:
|
||||||
"""A range object for Y coordinates."""
|
"""A range object for Y coordinates."""
|
||||||
return range(self.y, self.y + self.height)
|
return range(self.y, self.y + self.height)
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class HorizontalLayout(Layout):
|
|||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
|
|
||||||
x = max_width = max_height = Fraction(0)
|
x = max_width = max_height = Fraction(0)
|
||||||
parent_size = parent.size
|
parent_size = parent.outer_size
|
||||||
|
|
||||||
children = list(parent.children)
|
children = list(parent.children)
|
||||||
styles = [child.styles for child in children if child.styles.width is not None]
|
styles = [child.styles for child in children if child.styles.width is not None]
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class VerticalLayout(Layout):
|
|||||||
placements: list[WidgetPlacement] = []
|
placements: list[WidgetPlacement] = []
|
||||||
add_placement = placements.append
|
add_placement = placements.append
|
||||||
|
|
||||||
parent_size = parent.size
|
parent_size = parent.outer_size
|
||||||
|
|
||||||
children = list(parent.children)
|
children = list(parent.children)
|
||||||
styles = [child.styles for child in children if child.styles.height is not None]
|
styles = [child.styles for child in children if child.styles.height is not None]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import functools
|
import functools
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from rich.color import Color
|
from rich.color import Color
|
||||||
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
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
|
from textual.renderables._blend_colors import blend_colors
|
||||||
|
|
||||||
|
|
||||||
class Opacity:
|
@functools.lru_cache(maxsize=1024)
|
||||||
"""Wrap a renderable to blend foreground color into the background color.
|
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:
|
Args:
|
||||||
renderable (RenderableType): The RenderableType to manipulate.
|
bg_color (Color): Background color.
|
||||||
opacity (float): The opacity as a float. A value of 1.0 means text is fully visible.
|
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:
|
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.renderable = renderable
|
||||||
self.opacity = opacity
|
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__(
|
def __rich_console__(
|
||||||
self, console: Console, options: ConsoleOptions
|
self, console: Console, options: ConsoleOptions
|
||||||
) -> RenderResult:
|
) -> RenderResult:
|
||||||
segments = console.render(self.renderable, options)
|
segments = console.render(self.renderable, options)
|
||||||
opacity = self.opacity
|
return self.process_segments(segments, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
@@ -12,7 +13,7 @@ class Tint:
|
|||||||
"""Applies a color on top of an existing renderable."""
|
"""Applies a color on top of an existing renderable."""
|
||||||
|
|
||||||
def __init__(self, renderable: RenderableType, color: Color) -> None:
|
def __init__(self, renderable: RenderableType, color: Color) -> None:
|
||||||
"""_summary_
|
"""Wrap a renderable to apply a tint color.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
renderable (RenderableType): A renderable.
|
renderable (RenderableType): A renderable.
|
||||||
@@ -21,20 +22,29 @@ class Tint:
|
|||||||
self.renderable = renderable
|
self.renderable = renderable
|
||||||
self.color = color
|
self.color = color
|
||||||
|
|
||||||
def __rich_console__(
|
@classmethod
|
||||||
self, console: Console, options: ConsoleOptions
|
def process_segments(
|
||||||
) -> RenderResult:
|
cls, segments: Iterable[Segment], color: Color
|
||||||
segments = console.render(self.renderable, options)
|
) -> 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
|
from_rich_color = Color.from_rich_color
|
||||||
style_from_color = Style.from_color
|
style_from_color = Style.from_color
|
||||||
|
_Segment = Segment
|
||||||
for segment in segments:
|
for segment in segments:
|
||||||
text, style, control = segment
|
text, style, control = segment
|
||||||
if control or style is None:
|
if control or style is None:
|
||||||
yield segment
|
yield segment
|
||||||
else:
|
else:
|
||||||
yield Segment(
|
yield _Segment(
|
||||||
text,
|
text,
|
||||||
(
|
(
|
||||||
style
|
style
|
||||||
@@ -45,3 +55,10 @@ class Tint:
|
|||||||
),
|
),
|
||||||
control,
|
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:
|
def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
|
||||||
"""Refresh the layout (can change size and positions of widgets)."""
|
"""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:
|
if not size:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from typing import (
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
Any,
|
Any,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
|
Callable,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Collection,
|
Collection,
|
||||||
TYPE_CHECKING,
|
|
||||||
Callable,
|
|
||||||
Iterable,
|
Iterable,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
)
|
)
|
||||||
@@ -17,38 +17,35 @@ from rich.align import Align
|
|||||||
from rich.console import Console, RenderableType
|
from rich.console import Console, RenderableType
|
||||||
from rich.measure import Measurement
|
from rich.measure import Measurement
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
|
from rich.segment import Segment
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from . import errors
|
from . import errors, events, messages
|
||||||
from . import events
|
|
||||||
from ._animator import BoundAnimator
|
from ._animator import BoundAnimator
|
||||||
from ._border import Border
|
from ._border import Border
|
||||||
from .box_model import BoxModel, get_box_model
|
|
||||||
from ._context import active_app
|
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 ._types import Lines
|
||||||
|
from .box_model import BoxModel, get_box_model
|
||||||
from .dom import DOMNode
|
from .dom import DOMNode
|
||||||
from ._layout import ArrangeResult
|
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||||
from .geometry import clamp, Offset, Region, Size, Spacing
|
|
||||||
from .layouts.vertical import VerticalLayout
|
from .layouts.vertical import VerticalLayout
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from . import messages
|
|
||||||
from ._layout import Layout
|
|
||||||
from .reactive import Reactive, watch
|
from .reactive import Reactive, watch
|
||||||
from .renderables.opacity import Opacity
|
from .renderables.opacity import Opacity
|
||||||
from .renderables.tint import Tint
|
from .renderables.tint import Tint
|
||||||
from ._segment_tools import line_crop
|
|
||||||
from .css.styles import Styles
|
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .app import App, ComposeResult
|
from .app import App, ComposeResult
|
||||||
from .scrollbar import (
|
from .scrollbar import (
|
||||||
ScrollBar,
|
ScrollBar,
|
||||||
ScrollTo,
|
|
||||||
ScrollUp,
|
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
ScrollLeft,
|
ScrollLeft,
|
||||||
ScrollRight,
|
ScrollRight,
|
||||||
|
ScrollTo,
|
||||||
|
ScrollUp,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +115,8 @@ class Widget(DOMNode):
|
|||||||
self._arrangement: ArrangeResult | None = None
|
self._arrangement: ArrangeResult | None = None
|
||||||
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
self._arrangement_cache_key: tuple[int, Size] = (-1, Size())
|
||||||
|
|
||||||
|
self._styles_cache = StylesCache()
|
||||||
|
|
||||||
super().__init__(name=name, id=id, classes=classes)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
self.add_children(*children)
|
self.add_children(*children)
|
||||||
|
|
||||||
@@ -406,11 +405,6 @@ class Widget(DOMNode):
|
|||||||
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
|
||||||
return enabled
|
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
|
@property
|
||||||
def scrollbar_size_vertical(self) -> int:
|
def scrollbar_size_vertical(self) -> int:
|
||||||
"""Get the width used by the *vertical* scrollbar."""
|
"""Get the width used by the *vertical* scrollbar."""
|
||||||
@@ -427,6 +421,111 @@ class Widget(DOMNode):
|
|||||||
else 0
|
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:
|
def _set_dirty(self, *regions: Region) -> None:
|
||||||
"""Set the Widget as 'dirty' (requiring re-paint).
|
"""Set the Widget as 'dirty' (requiring re-paint).
|
||||||
|
|
||||||
@@ -439,12 +538,14 @@ class Widget(DOMNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if regions:
|
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:
|
else:
|
||||||
self._dirty_regions.clear()
|
self._dirty_regions.clear()
|
||||||
# TODO: Does this need to be content region?
|
self._styles_cache.clear()
|
||||||
# self._dirty_regions.append(self.size.region)
|
self._dirty_regions.add(self.outer_size.region)
|
||||||
self._dirty_regions.add(self.size.region)
|
|
||||||
|
|
||||||
def get_dirty_regions(self) -> Collection[Region]:
|
def get_dirty_regions(self) -> Collection[Region]:
|
||||||
"""Get regions which require a repaint.
|
"""Get regions which require a repaint.
|
||||||
@@ -664,7 +765,7 @@ class Widget(DOMNode):
|
|||||||
bool: True if the window was scrolled.
|
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:
|
if spacing is not None:
|
||||||
window = window.shrink(spacing)
|
window = window.shrink(spacing)
|
||||||
delta = Region.get_scroll_to_visible(window, region)
|
delta = Region.get_scroll_to_visible(window, region)
|
||||||
@@ -727,17 +828,17 @@ class Widget(DOMNode):
|
|||||||
region, _ = region.split_horizontal(-scrollbar_size_horizontal)
|
region, _ = region.split_horizontal(-scrollbar_size_horizontal)
|
||||||
return region
|
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.
|
"""Arrange the 'chrome' widgets (typically scrollbars) for a layout element.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
size (Size): Size of the containing region.
|
region (Region): The containing region.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region.
|
Iterable[tuple[Widget, Region]]: Tuples of scrollbar Widget and region.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
region = size.region
|
|
||||||
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
||||||
|
|
||||||
scrollbar_size_horizontal = self.scrollbar_size_horizontal
|
scrollbar_size_horizontal = self.scrollbar_size_horizontal
|
||||||
@@ -833,94 +934,14 @@ class Widget(DOMNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
renderable = self.render()
|
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
|
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:
|
def watch_mouse_over(self, value: bool) -> None:
|
||||||
"""Update from CSS if mouse over state changes."""
|
"""Update from CSS if mouse over state changes."""
|
||||||
self.app.update_styles()
|
self.app.update_styles()
|
||||||
@@ -953,23 +974,30 @@ class Widget(DOMNode):
|
|||||||
else:
|
else:
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _render_lines(self) -> None:
|
def _render_content(self) -> None:
|
||||||
"""Render all lines."""
|
"""Render all lines."""
|
||||||
width, height = self.size
|
width, height = self.size
|
||||||
renderable = self.render_styled()
|
renderable = self.render_styled()
|
||||||
options = self.console.options.update_dimensions(width, height).update(
|
options = self.console.options.update_dimensions(width, height).update(
|
||||||
highlight=False
|
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._render_cache = RenderCache(self.size, lines)
|
||||||
self._dirty_regions.clear()
|
self._dirty_regions.clear()
|
||||||
|
|
||||||
def _crop_lines(self, lines: Lines, x1, x2) -> Lines:
|
def render_line(self, y: int) -> list[Segment]:
|
||||||
width = self.size.width
|
"""Render a line of content.
|
||||||
if (x1, x2) != (0, width):
|
|
||||||
_line_crop = line_crop
|
Args:
|
||||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
y (int): Y Coordinate of line.
|
||||||
return lines
|
|
||||||
|
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:
|
def render_lines(self, crop: Region) -> Lines:
|
||||||
"""Render the widget in to lines.
|
"""Render the widget in to lines.
|
||||||
@@ -980,12 +1008,7 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
Lines: A list of list of segments
|
Lines: A list of list of segments
|
||||||
"""
|
"""
|
||||||
if self._dirty_regions:
|
lines = self._styles_cache.render_widget(self, crop)
|
||||||
self._render_lines()
|
|
||||||
|
|
||||||
x1, y1, x2, y2 = crop.corners
|
|
||||||
lines = self._render_cache.lines[y1:y2]
|
|
||||||
lines = self._crop_lines(lines, x1, x2)
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
def get_style_at(self, x: int, y: int) -> Style:
|
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._row_render_cache.clear()
|
||||||
self._cell_render_cache.clear()
|
self._cell_render_cache.clear()
|
||||||
self._line_cache.clear()
|
self._line_cache.clear()
|
||||||
|
self._styles_cache.clear()
|
||||||
|
|
||||||
def get_row_height(self, row_index: int) -> int:
|
def get_row_height(self, row_index: int) -> int:
|
||||||
if row_index == -1:
|
if row_index == -1:
|
||||||
@@ -264,7 +265,8 @@ class DataTable(ScrollView, Generic[CellType]):
|
|||||||
y = row.y
|
y = row.y
|
||||||
if self.show_header:
|
if self.show_header:
|
||||||
y += self.header_height
|
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:
|
def add_column(self, label: TextType, *, width: int = 10) -> None:
|
||||||
"""Add a column to the table.
|
"""Add a column to the table.
|
||||||
@@ -460,7 +462,7 @@ class DataTable(ScrollView, Generic[CellType]):
|
|||||||
list[Segment]: List of segments for rendering.
|
list[Segment]: List of segments for rendering.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
width = self.region.width
|
width = self.size.width
|
||||||
|
|
||||||
try:
|
try:
|
||||||
row_index, line_no = self._get_offsets(y)
|
row_index, line_no = self._get_offsets(y)
|
||||||
@@ -496,36 +498,40 @@ class DataTable(ScrollView, Generic[CellType]):
|
|||||||
self._line_cache[cache_key] = simplified_segments
|
self._line_cache[cache_key] = simplified_segments
|
||||||
return segments
|
return segments
|
||||||
|
|
||||||
def render_lines(self, crop: Region) -> Lines:
|
def render_line(self, y: int) -> list[Segment]:
|
||||||
"""Render lines within a given region.
|
"""Render a line of content.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
crop (Region): Region to crop to.
|
y (int): Y Coordinate of line.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Lines: A list of segments for every line within crop region.
|
list[Segment]: A rendered line.
|
||||||
"""
|
"""
|
||||||
scroll_y = self.scroll_offset.y
|
width, height = self.size
|
||||||
x1, y1, x2, y2 = crop.translate(self.scroll_offset).corners
|
scroll_x, scroll_y = self.scroll_offset
|
||||||
|
|
||||||
base_style = self.rich_style
|
|
||||||
|
|
||||||
fixed_top_row_count = sum(
|
fixed_top_row_count = sum(
|
||||||
self.get_row_height(row_index) for row_index in range(self.fixed_rows)
|
self.get_row_height(row_index) for row_index in range(self.fixed_rows)
|
||||||
)
|
)
|
||||||
if self.show_header:
|
if self.show_header:
|
||||||
fixed_top_row_count += self.get_row_height(-1)
|
fixed_top_row_count += self.get_row_height(-1)
|
||||||
|
|
||||||
render_line = self._render_line
|
style = self.rich_style
|
||||||
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 >= fixed_top_row_count:
|
||||||
if y - scroll_y < fixed_top_row_count:
|
y += scroll_y
|
||||||
lines[line_index] = fixed_lines[line_index]
|
|
||||||
|
|
||||||
|
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
|
return lines
|
||||||
|
|
||||||
def on_mouse_move(self, event: events.MouseMove):
|
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))
|
app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
|
||||||
await app.boot_and_shutdown()
|
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
|
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():
|
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():
|
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():
|
def test_region_x_max():
|
||||||
@@ -294,12 +294,12 @@ def test_region_y_max():
|
|||||||
assert Region(5, 10, 20, 30).bottom == 40
|
assert Region(5, 10, 20, 30).bottom == 40
|
||||||
|
|
||||||
|
|
||||||
def test_region_x_range():
|
def test_region_columns_range():
|
||||||
assert Region(5, 10, 20, 30).x_range == range(5, 25)
|
assert Region(5, 10, 20, 30).column_range == range(5, 25)
|
||||||
|
|
||||||
|
|
||||||
def test_region_y_range():
|
def test_region_lines_range():
|
||||||
assert Region(5, 10, 20, 30).y_range == range(10, 40)
|
assert Region(5, 10, 20, 30).line_range == range(10, 40)
|
||||||
|
|
||||||
|
|
||||||
def test_region_reset_offset():
|
def test_region_reset_offset():
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ async def test_composition_of_vertical_container_with_children(
|
|||||||
async with app.in_running_state():
|
async with app.in_running_state():
|
||||||
# root widget checks:
|
# root widget checks:
|
||||||
root_widget = cast(Widget, app.get_child("root"))
|
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
|
root_widget_region = app.screen.find_widget(root_widget).region
|
||||||
assert root_widget_region == (
|
assert root_widget_region == (
|
||||||
0,
|
0,
|
||||||
@@ -158,7 +158,7 @@ async def test_composition_of_vertical_container_with_children(
|
|||||||
|
|
||||||
# placeholder widgets checks:
|
# placeholder widgets checks:
|
||||||
for placeholder in app_placeholders:
|
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 placeholder.styles.offset.x.value == 0.0
|
||||||
assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x
|
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 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_style = app.screen.get_style_at(0, 0)
|
||||||
top_left_edge_color = top_left_edge_style.color.name
|
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 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():
|
def test_line_crop():
|
||||||
@@ -62,3 +62,52 @@ def test_line_crop_edge_2():
|
|||||||
expected = []
|
expected = []
|
||||||
print(repr(result))
|
print(repr(result))
|
||||||
assert result == expected
|
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
|
# We artificially tell the Compositor that the whole area should be refreshed
|
||||||
screen._compositor._dirty_regions = {
|
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)
|
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:
|
# 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