mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
adds Strip primitive
This commit is contained in:
@@ -26,10 +26,12 @@ from rich.style import Style
|
|||||||
from . import errors
|
from . import errors
|
||||||
from ._cells import cell_len
|
from ._cells import cell_len
|
||||||
from ._loop import loop_last
|
from ._loop import loop_last
|
||||||
from ._types import Lines
|
from .strip import Strip
|
||||||
|
from ._types import Strips
|
||||||
from ._typing import TypeAlias
|
from ._typing import TypeAlias
|
||||||
from .geometry import NULL_OFFSET, Offset, Region, Size
|
from .geometry import NULL_OFFSET, Offset, Region, Size
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
@@ -66,8 +68,8 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
|
|||||||
class LayoutUpdate:
|
class LayoutUpdate:
|
||||||
"""A renderable containing the result of a render for a given region."""
|
"""A renderable containing the result of a render for a given region."""
|
||||||
|
|
||||||
def __init__(self, lines: Lines, region: Region) -> None:
|
def __init__(self, strips: Strips, region: Region) -> None:
|
||||||
self.lines = lines
|
self.strips = strips
|
||||||
self.region = region
|
self.region = region
|
||||||
|
|
||||||
def __rich_console__(
|
def __rich_console__(
|
||||||
@@ -76,7 +78,7 @@ class LayoutUpdate:
|
|||||||
x = self.region.x
|
x = self.region.x
|
||||||
new_line = Segment.line()
|
new_line = Segment.line()
|
||||||
move_to = Control.move_to
|
move_to = Control.move_to
|
||||||
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
|
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
|
||||||
yield move_to(x, y)
|
yield move_to(x, y)
|
||||||
yield from line
|
yield from line
|
||||||
if not last:
|
if not last:
|
||||||
@@ -92,7 +94,7 @@ class ChopsUpdate:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
chops: list[dict[int, list[Segment] | None]],
|
chops: list[dict[int, Strip | None]],
|
||||||
spans: list[tuple[int, int, int]],
|
spans: list[tuple[int, int, int]],
|
||||||
chop_ends: list[list[int]],
|
chop_ends: list[list[int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -121,9 +123,9 @@ class ChopsUpdate:
|
|||||||
for y, x1, x2 in self.spans:
|
for y, x1, x2 in self.spans:
|
||||||
line = chops[y]
|
line = chops[y]
|
||||||
ends = chop_ends[y]
|
ends = chop_ends[y]
|
||||||
for end, (x, segments) in zip(ends, line.items()):
|
for end, (x, strip) in zip(ends, line.items()):
|
||||||
# TODO: crop to x extents
|
# TODO: crop to x extents
|
||||||
if segments is None:
|
if strip is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if x > x2 or end <= x1:
|
if x > x2 or end <= x1:
|
||||||
@@ -131,10 +133,10 @@ class ChopsUpdate:
|
|||||||
|
|
||||||
if x2 > x >= x1 and end <= x2:
|
if x2 > x >= x1 and end <= x2:
|
||||||
yield move_to(x, y)
|
yield move_to(x, y)
|
||||||
yield from segments
|
yield from strip
|
||||||
continue
|
continue
|
||||||
|
|
||||||
iter_segments = iter(segments)
|
iter_segments = iter(strip)
|
||||||
if x < x1:
|
if x < x1:
|
||||||
for segment in iter_segments:
|
for segment in iter_segments:
|
||||||
next_x = x + _cell_len(segment.text)
|
next_x = x + _cell_len(segment.text)
|
||||||
@@ -635,7 +637,7 @@ class Compositor:
|
|||||||
|
|
||||||
def _get_renders(
|
def _get_renders(
|
||||||
self, crop: Region | None = None
|
self, crop: Region | None = None
|
||||||
) -> Iterable[tuple[Region, Region, Lines]]:
|
) -> Iterable[tuple[Region, Region, Strips]]:
|
||||||
"""Get rendered widgets (lists of segments) in the composition.
|
"""Get rendered widgets (lists of segments) in the composition.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -685,19 +687,21 @@ class Compositor:
|
|||||||
_Region(delta_x, delta_y, new_width, new_height)
|
_Region(delta_x, delta_y, new_width, new_height)
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
# @classmethod
|
||||||
def _assemble_chops(
|
# def _assemble_chops(cls, chops: list[dict[int, Strip | None]]) -> list[Strip]:
|
||||||
cls, chops: list[dict[int, list[Segment] | None]]
|
# """Combine chops in to lines."""
|
||||||
) -> list[list[Segment]]:
|
|
||||||
"""Combine chops in to lines."""
|
|
||||||
from_iterable = chain.from_iterable
|
|
||||||
segment_lines: list[list[Segment]] = [
|
|
||||||
list(from_iterable(line for line in bucket.values() if line is not None))
|
|
||||||
for bucket in chops
|
|
||||||
]
|
|
||||||
return segment_lines
|
|
||||||
|
|
||||||
def render(self, full: bool = False) -> RenderableType | None:
|
# [Strip.join(strips) for strips in chops]
|
||||||
|
|
||||||
|
# from_iterable = chain.from_iterable
|
||||||
|
|
||||||
|
# segment_lines: list[list[Segment]] = [
|
||||||
|
# list(from_iterable(strip for strip in bucket.values() if strip is not None))
|
||||||
|
# for bucket in chops
|
||||||
|
# ]
|
||||||
|
# return segment_lines
|
||||||
|
|
||||||
|
def render(self, full: bool = False) -> RenderableType:
|
||||||
"""Render a layout.
|
"""Render a layout.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -728,8 +732,6 @@ class Compositor:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
divide = Segment.divide
|
|
||||||
|
|
||||||
# Maps each cut on to a list of segments
|
# Maps each cut on to a list of segments
|
||||||
cuts = self.cuts
|
cuts = self.cuts
|
||||||
|
|
||||||
@@ -738,19 +740,19 @@ class Compositor:
|
|||||||
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
|
"Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
|
||||||
)
|
)
|
||||||
# A mapping of cut index to a list of segments for each line
|
# A mapping of cut index to a list of segments for each line
|
||||||
chops: list[dict[int, list[Segment] | None]]
|
chops: list[dict[int, Strip | None]]
|
||||||
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
|
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
|
||||||
|
|
||||||
cut_segments: Iterable[list[Segment]]
|
cut_strips: Iterable[Strip]
|
||||||
|
|
||||||
# Go through all the renders in reverse order and fill buckets with no render
|
# Go through all the renders in reverse order and fill buckets with no render
|
||||||
renders = self._get_renders(crop)
|
renders = self._get_renders(crop)
|
||||||
intersection = Region.intersection
|
intersection = Region.intersection
|
||||||
|
|
||||||
for region, clip, lines in renders:
|
for region, clip, strips in renders:
|
||||||
render_region = intersection(region, clip)
|
render_region = intersection(region, clip)
|
||||||
|
|
||||||
for y, line in zip(render_region.line_range, lines):
|
for y, strip in zip(render_region.line_range, strips):
|
||||||
if not is_rendered_line(y):
|
if not is_rendered_line(y):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -763,20 +765,20 @@ class Compositor:
|
|||||||
]
|
]
|
||||||
if len(final_cuts) <= 2:
|
if len(final_cuts) <= 2:
|
||||||
# Two cuts, which means the entire line
|
# Two cuts, which means the entire line
|
||||||
cut_segments = [line]
|
cut_strips = [strip]
|
||||||
else:
|
else:
|
||||||
render_x = render_region.x
|
render_x = render_region.x
|
||||||
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
|
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
|
||||||
cut_segments = divide(line, relative_cuts)
|
cut_strips = list(strip.divide(relative_cuts))
|
||||||
|
|
||||||
# Since we are painting front to back, the first segments for a cut "wins"
|
# Since we are painting front to back, the first segments for a cut "wins"
|
||||||
for cut, segments in zip(final_cuts, cut_segments):
|
for cut, segments in zip(final_cuts, cut_strips):
|
||||||
if chops_line[cut] is None:
|
if chops_line[cut] is None:
|
||||||
chops_line[cut] = segments
|
chops_line[cut] = segments
|
||||||
|
|
||||||
if full:
|
if full:
|
||||||
render_lines = self._assemble_chops(chops)
|
render_strips = [Strip.join(chop.values()) for chop in chops]
|
||||||
return LayoutUpdate(render_lines, screen_region)
|
return LayoutUpdate(render_strips, screen_region)
|
||||||
else:
|
else:
|
||||||
chop_ends = [cut_set[1:] for cut_set in cuts]
|
chop_ends = [cut_set[1:] for cut_set in cuts]
|
||||||
return ChopsUpdate(chops, spans, chop_ends)
|
return ChopsUpdate(chops, spans, chop_ends)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from rich.segment import Segment
|
|||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from ._cells import cell_len
|
from ._cells import cell_len
|
||||||
from ._types import Lines
|
from ._types import Strips
|
||||||
from .css.types import AlignHorizontal, AlignVertical
|
from .css.types import AlignHorizontal, AlignVertical
|
||||||
from .geometry import Size
|
from .geometry import Size
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ def line_crop(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
segments (list[Segment]): A list of Segments for a line.
|
segments (list[Segment]): A list of Segments for a line.
|
||||||
start (int): Start offset
|
start (int): Start offset (cells)
|
||||||
end (int): End offset (exclusive)
|
end (int): End offset (cells, exclusive)
|
||||||
total (int): Total cell length of segments.
|
total (int): Total cell length of segments.
|
||||||
Returns:
|
Returns:
|
||||||
list[Segment]: A new shorter list of segments
|
list[Segment]: A new shorter list of segments
|
||||||
@@ -130,7 +130,7 @@ def line_pad(
|
|||||||
|
|
||||||
|
|
||||||
def align_lines(
|
def align_lines(
|
||||||
lines: Lines,
|
lines: Strips,
|
||||||
style: Style,
|
style: Style,
|
||||||
size: Size,
|
size: Size,
|
||||||
horizontal: AlignHorizontal,
|
horizontal: AlignHorizontal,
|
||||||
@@ -153,7 +153,7 @@ def align_lines(
|
|||||||
width, height = size
|
width, height = size
|
||||||
shape_width, shape_height = Segment.get_shape(lines)
|
shape_width, shape_height = Segment.get_shape(lines)
|
||||||
|
|
||||||
def blank_lines(count: int) -> Lines:
|
def blank_lines(count: int) -> Strips:
|
||||||
return [[Segment(" " * width, style)]] * count
|
return [[Segment(" " * width, style)]] * count
|
||||||
|
|
||||||
top_blank_lines = bottom_blank_lines = 0
|
top_blank_lines = bottom_blank_lines = 0
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ from ._border import get_box, render_row
|
|||||||
from ._filter import LineFilter
|
from ._filter import LineFilter
|
||||||
from ._opacity import _apply_opacity
|
from ._opacity import _apply_opacity
|
||||||
from ._segment_tools import line_crop, line_pad, line_trim
|
from ._segment_tools import line_crop, line_pad, line_trim
|
||||||
from ._types import Lines
|
from ._types import Strips
|
||||||
from ._typing import TypeAlias
|
from ._typing import TypeAlias
|
||||||
from .color import Color
|
from .color import Color
|
||||||
from .geometry import Region, Size, Spacing
|
from .geometry import Region, Size, Spacing
|
||||||
from .renderables.text_opacity import TextOpacity
|
from .renderables.text_opacity import TextOpacity
|
||||||
from .renderables.tint import Tint
|
from .renderables.tint import Tint
|
||||||
|
from .strip import Strip
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .css.styles import StylesBase
|
from .css.styles import StylesBase
|
||||||
@@ -25,35 +26,6 @@ if TYPE_CHECKING:
|
|||||||
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]
|
RenderLineCallback: TypeAlias = Callable[[int], List[Segment]]
|
||||||
|
|
||||||
|
|
||||||
def style_links(
|
|
||||||
segments: Iterable[Segment], link_id: str, link_style: Style
|
|
||||||
) -> list[Segment]:
|
|
||||||
"""Apply a style to the given link id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
segments (Iterable[Segment]): Segments.
|
|
||||||
link_id (str): A link id.
|
|
||||||
link_style (Style): Style to apply.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[Segment]: A list of new segments.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_Segment = Segment
|
|
||||||
|
|
||||||
segments = [
|
|
||||||
_Segment(
|
|
||||||
text,
|
|
||||||
(style + link_style if style is not None else None)
|
|
||||||
if (style and not style._null and style._link_id == link_id)
|
|
||||||
else style,
|
|
||||||
control,
|
|
||||||
)
|
|
||||||
for text, style, control in segments
|
|
||||||
]
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(1024 * 8)
|
@lru_cache(1024 * 8)
|
||||||
def make_blank(width, style: Style) -> Segment:
|
def make_blank(width, style: Style) -> Segment:
|
||||||
"""Make a blank segment.
|
"""Make a blank segment.
|
||||||
@@ -95,7 +67,7 @@ class StylesCache:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._cache: dict[int, list[Segment]] = {}
|
self._cache: dict[int, Strip] = {}
|
||||||
self._dirty_lines: set[int] = set()
|
self._dirty_lines: set[int] = set()
|
||||||
self._width = 1
|
self._width = 1
|
||||||
|
|
||||||
@@ -123,7 +95,7 @@ class StylesCache:
|
|||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
self._dirty_lines.clear()
|
self._dirty_lines.clear()
|
||||||
|
|
||||||
def render_widget(self, widget: Widget, crop: Region) -> Lines:
|
def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
|
||||||
"""Render the content for a widget.
|
"""Render the content for a widget.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -135,7 +107,7 @@ class StylesCache:
|
|||||||
"""
|
"""
|
||||||
base_background, background = widget.background_colors
|
base_background, background = widget.background_colors
|
||||||
styles = widget.styles
|
styles = widget.styles
|
||||||
lines = self.render(
|
strips = self.render(
|
||||||
styles,
|
styles,
|
||||||
widget.region.size,
|
widget.region.size,
|
||||||
base_background,
|
base_background,
|
||||||
@@ -147,7 +119,6 @@ class StylesCache:
|
|||||||
filter=widget.app._filter,
|
filter=widget.app._filter,
|
||||||
)
|
)
|
||||||
if widget.auto_links:
|
if widget.auto_links:
|
||||||
_style_links = style_links
|
|
||||||
hover_style = widget.hover_style
|
hover_style = widget.hover_style
|
||||||
link_hover_style = widget.link_hover_style
|
link_hover_style = widget.link_hover_style
|
||||||
if (
|
if (
|
||||||
@@ -157,12 +128,12 @@ class StylesCache:
|
|||||||
and "@click" in hover_style.meta
|
and "@click" in hover_style.meta
|
||||||
):
|
):
|
||||||
if link_hover_style:
|
if link_hover_style:
|
||||||
lines = [
|
strips = [
|
||||||
_style_links(line, hover_style.link_id, link_hover_style)
|
strip.style_links(hover_style.link_id, link_hover_style)
|
||||||
for line in lines
|
for strip in strips
|
||||||
]
|
]
|
||||||
|
|
||||||
return lines
|
return strips
|
||||||
|
|
||||||
def render(
|
def render(
|
||||||
self,
|
self,
|
||||||
@@ -175,7 +146,7 @@ class StylesCache:
|
|||||||
padding: Spacing | None = None,
|
padding: Spacing | None = None,
|
||||||
crop: Region | None = None,
|
crop: Region | None = None,
|
||||||
filter: LineFilter | None = None,
|
filter: LineFilter | None = None,
|
||||||
) -> Lines:
|
) -> list[Strip]:
|
||||||
"""Render a widget content plus CSS styles.
|
"""Render a widget content plus CSS styles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -202,15 +173,14 @@ class StylesCache:
|
|||||||
if width != self._width:
|
if width != self._width:
|
||||||
self.clear()
|
self.clear()
|
||||||
self._width = width
|
self._width = width
|
||||||
lines: Lines = []
|
strips: list[Strip] = []
|
||||||
add_line = lines.append
|
add_strip = strips.append
|
||||||
simplify = Segment.simplify
|
|
||||||
|
|
||||||
is_dirty = self._dirty_lines.__contains__
|
is_dirty = self._dirty_lines.__contains__
|
||||||
render_line = self.render_line
|
render_line = self.render_line
|
||||||
for y in crop.line_range:
|
for y in crop.line_range:
|
||||||
if is_dirty(y) or y not in self._cache:
|
if is_dirty(y) or y not in self._cache:
|
||||||
line = render_line(
|
strip = render_line(
|
||||||
styles,
|
styles,
|
||||||
y,
|
y,
|
||||||
size,
|
size,
|
||||||
@@ -220,21 +190,19 @@ class StylesCache:
|
|||||||
background,
|
background,
|
||||||
render_content_line,
|
render_content_line,
|
||||||
)
|
)
|
||||||
line = list(simplify(line))
|
self._cache[y] = strip
|
||||||
self._cache[y] = line
|
|
||||||
else:
|
else:
|
||||||
line = self._cache[y]
|
strip = self._cache[y]
|
||||||
if filter:
|
if filter:
|
||||||
line = filter.filter(line)
|
strip = strip.apply_filter(filter)
|
||||||
add_line(line)
|
add_strip(strip)
|
||||||
self._dirty_lines.difference_update(crop.line_range)
|
self._dirty_lines.difference_update(crop.line_range)
|
||||||
|
|
||||||
if crop.column_span != (0, width):
|
if crop.column_span != (0, width):
|
||||||
_line_crop = line_crop
|
|
||||||
x1, x2 = crop.column_span
|
x1, x2 = crop.column_span
|
||||||
lines = [_line_crop(line, x1, x2, width) for line in lines]
|
strips = [strip.crop(x1, x2) for strip in strips]
|
||||||
|
|
||||||
return lines
|
return strips
|
||||||
|
|
||||||
def render_line(
|
def render_line(
|
||||||
self,
|
self,
|
||||||
@@ -246,7 +214,7 @@ class StylesCache:
|
|||||||
base_background: Color,
|
base_background: Color,
|
||||||
background: Color,
|
background: Color,
|
||||||
render_content_line: RenderLineCallback,
|
render_content_line: RenderLineCallback,
|
||||||
) -> list[Segment]:
|
) -> Strip:
|
||||||
"""Render a styled line.
|
"""Render a styled line.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -402,4 +370,5 @@ class StylesCache:
|
|||||||
else:
|
else:
|
||||||
line = [*line, right]
|
line = [*line, right]
|
||||||
|
|
||||||
return post(line)
|
strip = Strip(post(line), width)
|
||||||
|
return strip
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ from typing import Awaitable, Callable, List, TYPE_CHECKING, Union
|
|||||||
|
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
|
|
||||||
from textual._typing import Protocol
|
from ._typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .message import Message
|
from .message import Message
|
||||||
|
from .strip import Strip
|
||||||
|
|
||||||
|
|
||||||
class MessageTarget(Protocol):
|
class MessageTarget(Protocol):
|
||||||
@@ -27,5 +29,5 @@ class EventTarget(Protocol):
|
|||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
Lines = List[List[Segment]]
|
Strips = List["Strip"]
|
||||||
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ class ScrollView(Widget):
|
|||||||
"""Not transparent, i.e. renders something."""
|
"""Not transparent, i.e. renders something."""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def watch_scroll_x(self, new_value: float) -> None:
|
||||||
|
if self.show_horizontal_scrollbar:
|
||||||
|
self.horizontal_scrollbar.position = int(new_value)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def watch_scroll_y(self, new_value: float) -> None:
|
||||||
|
if self.show_vertical_scrollbar:
|
||||||
|
self.vertical_scrollbar.position = int(new_value)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
self._refresh_scrollbars()
|
self._refresh_scrollbars()
|
||||||
|
|
||||||
@@ -68,6 +78,8 @@ class ScrollView(Widget):
|
|||||||
virtual_size (Size): New virtual size.
|
virtual_size (Size): New virtual size.
|
||||||
container_size (Size): New container size.
|
container_size (Size): New container size.
|
||||||
"""
|
"""
|
||||||
|
if self._size != size or container_size != container_size:
|
||||||
|
self.refresh()
|
||||||
if (
|
if (
|
||||||
self._size != size
|
self._size != size
|
||||||
or virtual_size != self.virtual_size
|
or virtual_size != self.virtual_size
|
||||||
@@ -77,9 +89,7 @@ class ScrollView(Widget):
|
|||||||
virtual_size = self.virtual_size
|
virtual_size = self.virtual_size
|
||||||
self._container_size = size - self.styles.gutter.totals
|
self._container_size = size - self.styles.gutter.totals
|
||||||
self._scroll_update(virtual_size)
|
self._scroll_update(virtual_size)
|
||||||
|
|
||||||
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
"""Render the scrollable region (if `render_lines` is not implemented).
|
"""Render the scrollable region (if `render_lines` is not implemented).
|
||||||
|
|||||||
181
src/textual/strip.py
Normal file
181
src/textual/strip.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from itertools import chain
|
||||||
|
from typing import Iterator, Iterable, Sequence
|
||||||
|
|
||||||
|
import rich.repr
|
||||||
|
from rich.cells import cell_len, set_cell_size
|
||||||
|
from rich.segment import Segment
|
||||||
|
from rich.style import Style
|
||||||
|
|
||||||
|
from ._filter import LineFilter
|
||||||
|
from ._segment_tools import line_crop
|
||||||
|
|
||||||
|
from ._profile import timer
|
||||||
|
|
||||||
|
|
||||||
|
@rich.repr.auto
|
||||||
|
class Strip:
|
||||||
|
__slots__ = ["_segments", "_cell_length", "_divide_cache"]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, segments: Iterable[Segment], cell_length: int | None = None
|
||||||
|
) -> None:
|
||||||
|
self._segments = list(segments)
|
||||||
|
self._cell_length = cell_length
|
||||||
|
self._divide_cache: dict[tuple[int], list[Strip]] = {}
|
||||||
|
|
||||||
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
|
yield self._segments
|
||||||
|
yield self.cell_length
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cell_length(self) -> int:
|
||||||
|
"""Get the number of cells required to render this object."""
|
||||||
|
# Done on demand and cached, as this is an O(n) operation
|
||||||
|
if self._cell_length is None:
|
||||||
|
self._cell_length = Segment.get_line_length(self._segments)
|
||||||
|
return self._cell_length
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def join(cls, strips: Iterable[Strip | None]) -> Strip:
|
||||||
|
|
||||||
|
segments: list[list[Segment]] = []
|
||||||
|
add_segments = segments.append
|
||||||
|
total_cell_length = 0
|
||||||
|
for strip in strips:
|
||||||
|
if strip is None:
|
||||||
|
continue
|
||||||
|
total_cell_length += strip.cell_length
|
||||||
|
add_segments(strip._segments)
|
||||||
|
strip = cls(chain.from_iterable(segments), total_cell_length)
|
||||||
|
return strip
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self._segments)
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[Segment]:
|
||||||
|
return iter(self._segments)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._segments)
|
||||||
|
|
||||||
|
def __eq__(self, strip: Strip) -> bool:
|
||||||
|
return (
|
||||||
|
self._segments == strip._segments and self.cell_length == strip.cell_length
|
||||||
|
)
|
||||||
|
|
||||||
|
def adjust_line_length(self, cell_length: int, style: Style | None) -> Strip:
|
||||||
|
|
||||||
|
new_line: list[Segment]
|
||||||
|
line = self._segments
|
||||||
|
current_cell_length = self.cell_length
|
||||||
|
|
||||||
|
_Segment = Segment
|
||||||
|
|
||||||
|
if current_cell_length < cell_length:
|
||||||
|
new_line = line + [
|
||||||
|
_Segment(" " * (cell_length - current_cell_length), style)
|
||||||
|
]
|
||||||
|
|
||||||
|
elif current_cell_length > cell_length:
|
||||||
|
new_line = []
|
||||||
|
append = new_line.append
|
||||||
|
line_length = 0
|
||||||
|
for segment in line:
|
||||||
|
segment_length = segment.cell_length
|
||||||
|
if line_length + segment_length < cell_length:
|
||||||
|
append(segment)
|
||||||
|
line_length += segment_length
|
||||||
|
else:
|
||||||
|
text, segment_style, _ = segment
|
||||||
|
text = set_cell_size(text, cell_length - line_length)
|
||||||
|
append(_Segment(text, segment_style))
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return self
|
||||||
|
|
||||||
|
return Strip(new_line, cell_length)
|
||||||
|
|
||||||
|
def simplify(self) -> Strip:
|
||||||
|
line = Strip(
|
||||||
|
Segment.simplify(self._segments),
|
||||||
|
self._cell_length,
|
||||||
|
)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def apply_filter(self, filter: LineFilter) -> Strip:
|
||||||
|
return Strip(filter.filter(self._segments), self._cell_length)
|
||||||
|
|
||||||
|
def style_links(self, link_id: str, link_style: Style) -> Strip:
|
||||||
|
_Segment = Segment
|
||||||
|
if not any(
|
||||||
|
segment.style._link_id == link_id
|
||||||
|
for segment in self._segments
|
||||||
|
if segment.style
|
||||||
|
):
|
||||||
|
return self
|
||||||
|
segments = [
|
||||||
|
_Segment(
|
||||||
|
text,
|
||||||
|
(style + link_style if style is not None else None)
|
||||||
|
if (style and not style._null and style._link_id == link_id)
|
||||||
|
else style,
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
for text, style, control in self._segments
|
||||||
|
]
|
||||||
|
return Strip(segments, self._cell_length)
|
||||||
|
|
||||||
|
def crop(self, start: int, end: int) -> Strip:
|
||||||
|
_cell_len = cell_len
|
||||||
|
pos = 0
|
||||||
|
output_segments: list[Segment] = []
|
||||||
|
add_segment = output_segments.append
|
||||||
|
iter_segments = iter(self._segments)
|
||||||
|
segment: Segment | None = None
|
||||||
|
for segment in iter_segments:
|
||||||
|
end_pos = pos + _cell_len(segment.text)
|
||||||
|
if end_pos > start:
|
||||||
|
segment = segment.split_cells(start - pos)[1]
|
||||||
|
break
|
||||||
|
pos = end_pos
|
||||||
|
else:
|
||||||
|
return Strip([], 0)
|
||||||
|
|
||||||
|
if end >= self.cell_length:
|
||||||
|
# The end crop is the end of the segments, so we can collect all remaining segments
|
||||||
|
if segment:
|
||||||
|
add_segment(segment)
|
||||||
|
output_segments.extend(iter_segments)
|
||||||
|
return Strip(output_segments, self.cell_length - start)
|
||||||
|
|
||||||
|
pos = start
|
||||||
|
while segment is not None:
|
||||||
|
end_pos = pos + _cell_len(segment.text)
|
||||||
|
if end_pos < end:
|
||||||
|
add_segment(segment)
|
||||||
|
else:
|
||||||
|
add_segment(segment.split_cells(end - pos)[0])
|
||||||
|
break
|
||||||
|
pos = end_pos
|
||||||
|
segment = next(iter_segments, None)
|
||||||
|
return Strip(output_segments, end - start)
|
||||||
|
|
||||||
|
def divide(self, cuts: Iterable[int]) -> list[Strip]:
|
||||||
|
|
||||||
|
pos = 0
|
||||||
|
cache_key = tuple(cuts)
|
||||||
|
cached = self._divide_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
strips: list[Strip] = []
|
||||||
|
add_strip = strips.append
|
||||||
|
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||||||
|
add_strip(Strip(segments, cut - pos))
|
||||||
|
pos += cut
|
||||||
|
|
||||||
|
self._divide_cache[cache_key] = strips
|
||||||
|
|
||||||
|
return strips
|
||||||
@@ -43,7 +43,7 @@ from ._easing import DEFAULT_SCROLL_EASING
|
|||||||
from ._layout import Layout
|
from ._layout import Layout
|
||||||
from ._segment_tools import align_lines
|
from ._segment_tools import align_lines
|
||||||
from ._styles_cache import StylesCache
|
from ._styles_cache import StylesCache
|
||||||
from ._types import Lines
|
from ._types import Strips
|
||||||
from .actions import SkipAction
|
from .actions import SkipAction
|
||||||
from .await_remove import AwaitRemove
|
from .await_remove import AwaitRemove
|
||||||
from .binding import Binding
|
from .binding import Binding
|
||||||
@@ -57,6 +57,7 @@ from .message import Message
|
|||||||
from .messages import CallbackType
|
from .messages import CallbackType
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .render import measure
|
from .render import measure
|
||||||
|
from .strip import Strip
|
||||||
from .walk import walk_depth_first
|
from .walk import walk_depth_first
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -156,7 +157,7 @@ class RenderCache(NamedTuple):
|
|||||||
"""Stores results of a previous render."""
|
"""Stores results of a previous render."""
|
||||||
|
|
||||||
size: Size
|
size: Size
|
||||||
lines: Lines
|
lines: Strips
|
||||||
|
|
||||||
|
|
||||||
class WidgetError(Exception):
|
class WidgetError(Exception):
|
||||||
@@ -2118,7 +2119,7 @@ class Widget(DOMNode):
|
|||||||
line = [Segment(" " * self.size.width, self.rich_style)]
|
line = [Segment(" " * self.size.width, self.rich_style)]
|
||||||
return line
|
return line
|
||||||
|
|
||||||
def render_lines(self, crop: Region) -> Lines:
|
def render_lines(self, crop: Region) -> list[Strip]:
|
||||||
"""Render the widget in to lines.
|
"""Render the widget in to lines.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -2127,8 +2128,8 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
Lines: A list of list of segments.
|
Lines: A list of list of segments.
|
||||||
"""
|
"""
|
||||||
lines = self._styles_cache.render_widget(self, crop)
|
strips = self._styles_cache.render_widget(self, crop)
|
||||||
return lines
|
return strips
|
||||||
|
|
||||||
def get_style_at(self, x: int, y: int) -> Style:
|
def get_style_at(self, x: int, y: int) -> Style:
|
||||||
"""Get the Rich style in a widget at a given relative offset.
|
"""Get the Rich style in a widget at a given relative offset.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from rich.text import Text, TextType
|
|||||||
from .. import events, messages
|
from .. import events, messages
|
||||||
from .._cache import LRUCache
|
from .._cache import LRUCache
|
||||||
from .._segment_tools import line_crop
|
from .._segment_tools import line_crop
|
||||||
from .._types import Lines
|
from .._types import Strips
|
||||||
from ..geometry import Region, Size, Spacing, clamp
|
from ..geometry import Region, Size, Spacing, clamp
|
||||||
from ..reactive import Reactive
|
from ..reactive import Reactive
|
||||||
from ..render import measure
|
from ..render import measure
|
||||||
@@ -207,10 +207,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
self.row_count = 0
|
self.row_count = 0
|
||||||
self._y_offsets: list[tuple[int, int]] = []
|
self._y_offsets: list[tuple[int, int]] = []
|
||||||
self._row_render_cache: LRUCache[
|
self._row_render_cache: LRUCache[
|
||||||
tuple[int, int, Style, int, int], tuple[Lines, Lines]
|
tuple[int, int, Style, int, int], tuple[Strips, Strips]
|
||||||
]
|
]
|
||||||
self._row_render_cache = LRUCache(1000)
|
self._row_render_cache = LRUCache(1000)
|
||||||
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines]
|
self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Strips]
|
||||||
self._cell_render_cache = LRUCache(10000)
|
self._cell_render_cache = LRUCache(10000)
|
||||||
self._line_cache: LRUCache[
|
self._line_cache: LRUCache[
|
||||||
tuple[int, int, int, int, int, int, Style], list[Segment]
|
tuple[int, int, int, int, int, int, Style], list[Segment]
|
||||||
@@ -450,7 +450,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
width: int,
|
width: int,
|
||||||
cursor: bool = False,
|
cursor: bool = False,
|
||||||
hover: bool = False,
|
hover: bool = False,
|
||||||
) -> Lines:
|
) -> Strips:
|
||||||
"""Render the given cell.
|
"""Render the given cell.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -488,7 +488,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
base_style: Style,
|
base_style: Style,
|
||||||
cursor_column: int = -1,
|
cursor_column: int = -1,
|
||||||
hover_column: int = -1,
|
hover_column: int = -1,
|
||||||
) -> tuple[Lines, Lines]:
|
) -> tuple[Strips, Strips]:
|
||||||
"""Render a row in to lines for each cell.
|
"""Render a row in to lines for each cell.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ..geometry import Size, Region
|
|||||||
from ..scroll_view import ScrollView
|
from ..scroll_view import ScrollView
|
||||||
from .._cache import LRUCache
|
from .._cache import LRUCache
|
||||||
from .._segment_tools import line_crop
|
from .._segment_tools import line_crop
|
||||||
from .._types import Lines
|
from .._types import Strips
|
||||||
|
|
||||||
|
|
||||||
class TextLog(ScrollView, can_focus=True):
|
class TextLog(ScrollView, can_focus=True):
|
||||||
@@ -143,7 +143,7 @@ class TextLog(ScrollView, can_focus=True):
|
|||||||
line = list(Segment.apply_style(line, self.rich_style))
|
line = list(Segment.apply_style(line, self.rich_style))
|
||||||
return line
|
return line
|
||||||
|
|
||||||
def render_lines(self, crop: Region) -> Lines:
|
def render_lines(self, crop: Region) -> Strips:
|
||||||
"""Render the widget in to lines.
|
"""Render the widget in to lines.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,13 +4,14 @@ from rich.segment import Segment
|
|||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
|
|
||||||
from textual._styles_cache import StylesCache
|
from textual._styles_cache import StylesCache
|
||||||
from textual._types import Lines
|
from textual._types import Strips
|
||||||
from textual.color import Color
|
from textual.color import Color
|
||||||
from textual.css.styles import Styles
|
from textual.css.styles import Styles
|
||||||
from textual.geometry import Region, Size
|
from textual.geometry import Region, Size
|
||||||
|
from textual.strip import Strip
|
||||||
|
|
||||||
|
|
||||||
def _extract_content(lines: Lines):
|
def _extract_content(lines: Strips):
|
||||||
"""Extract the text content from lines."""
|
"""Extract the text content from lines."""
|
||||||
content = ["".join(segment.text for segment in line) for line in lines]
|
content = ["".join(segment.text for segment in line) for line in lines]
|
||||||
return content
|
return content
|
||||||
@@ -44,10 +45,11 @@ def test_no_styles():
|
|||||||
)
|
)
|
||||||
style = Style.from_color(bgcolor=Color.parse("green").rich_color)
|
style = Style.from_color(bgcolor=Color.parse("green").rich_color)
|
||||||
expected = [
|
expected = [
|
||||||
[Segment("foo", style)],
|
Strip([Segment("foo", style)], 3),
|
||||||
[Segment("bar", style)],
|
Strip([Segment("bar", style)], 3),
|
||||||
[Segment("baz", style)],
|
Strip([Segment("baz", style)], 3),
|
||||||
]
|
]
|
||||||
|
|
||||||
assert lines == expected
|
assert lines == expected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user