ai-station/.venv/lib/python3.12/site-packages/textual/_compositor.py

1270 lines
43 KiB
Python
Raw Permalink Normal View History

2025-12-25 14:54:33 +00:00
"""
The compositor handles combining widgets into a single screen (i.e. compositing).
It also stores the results of that process, so that Textual knows the widgets on
the screen and their locations. The compositor uses this information to answer
queries regarding the widget under an offset, or the style under an offset.
Additionally, the compositor can render portions of the screen which may have updated,
without having to render the entire screen.
"""
from __future__ import annotations
from operator import itemgetter
from typing import (
TYPE_CHECKING,
Callable,
Iterable,
Mapping,
NamedTuple,
Sequence,
cast,
)
import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.control import Control
from rich.segment import Segment
from rich.style import Style
from textual import errors
from textual._cells import cell_len
from textual._context import visible_screen_stack
from textual._loop import loop_last
from textual.geometry import NULL_SPACING, Offset, Region, Size, Spacing
from textual.map_geometry import MapGeometry
from textual.strip import Strip, StripRenderable
from textual.widget import Widget
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from textual.screen import Screen
class ReflowResult(NamedTuple):
"""The result of a reflow operation. Describes the chances to widgets."""
hidden: set[Widget] # Widgets that are hidden
shown: set[Widget] # Widgets that are shown
resized: set[Widget] # Widgets that have been resized
# Maps a widget on to its geometry (information that describes its position in the composition)
CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
class CompositorUpdate:
"""An update generated by the compositor, which also doubles as console renderables."""
def render_segments(self, console: Console) -> str:
"""Render the update to raw data, suitable for writing to terminal.
Args:
console: Console instance.
Returns:
Raw data with escape sequences.
"""
return ""
@rich.repr.auto(angular=True)
class LayoutUpdate(CompositorUpdate):
"""A renderable containing the result of a render for a given region."""
def __init__(self, strips: list[Iterable[Strip]], region: Region) -> None:
self.strips = strips
self.region = region
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
x = self.region.x
new_line = Segment.line()
move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
yield move_to(x, y).segment
for strip in line:
yield from strip
if not last:
yield new_line
def render_segments(self, console: Console) -> str:
"""Render the update to raw data, suitable for writing to terminal.
Args:
console: Console instance.
Returns:
Raw data with escape sequences.
"""
sequences: list[str] = []
append = sequences.append
extend = sequences.extend
x = self.region.x
move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
append(move_to(x, y).segment.text)
extend([strip.render(console) for strip in line])
if not last:
append("\n")
return "".join(sequences)
def __rich_repr__(self) -> rich.repr.Result:
yield self.region
@rich.repr.auto(angular=True)
class InlineUpdate(CompositorUpdate):
"""A renderable to write an inline update."""
def __init__(self, strips: list[Strip], clear: bool = False) -> None:
self.strips = strips
self.clear = clear
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
new_line = Segment.line()
for last, line in loop_last(self.strips):
yield from line
if not last:
yield new_line
def render_segments(self, console: Console) -> str:
"""Render the update to raw data, suitable for writing to terminal.
Args:
console: Console instance.
Returns:
Raw data with escape sequences.
"""
sequences: list[str] = []
append = sequences.append
for last, strip in loop_last(self.strips):
append(strip.render(console))
if not last:
append("\n")
if self.clear:
if len(self.strips) > 1:
append("\n")
append("\x1b[J") # Clear down
if len(self.strips) > 1:
back_lines = len(self.strips) if self.clear else len(self.strips) - 1
append(f"\x1b[{back_lines}A\r") # Move cursor back to original position
else:
append("\r")
append("\x1b[6n") # Query new cursor position
return "".join(sequences)
@rich.repr.auto(angular=True)
class ChopsUpdate(CompositorUpdate):
"""A renderable that applies updated spans to the screen."""
def __init__(
self,
chops: Sequence[Mapping[int, Strip | None]],
spans: list[tuple[int, int, int]],
chop_ends: list[list[int]],
) -> None:
"""A renderable which updates chops (fragments of lines).
Args:
chops: A mapping of offsets to list of segments, per line.
crop: Region to restrict update to.
chop_ends: A list of the end offsets for each line
"""
self.chops = chops
self.spans = spans
self.chop_ends = chop_ends
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
move_to = Control.move_to
new_line = Segment.line()
chops = self.chops
chop_ends = self.chop_ends
last_y = self.spans[-1][0]
_cell_len = cell_len
for y, x1, x2 in self.spans:
line = chops[y]
ends = chop_ends[y]
for end, (x, strip) in zip(ends, line.items()):
# TODO: crop to x extents
if strip is None:
continue
if x > x2 or end <= x1:
continue
if x2 > x >= x1 and end <= x2:
yield move_to(x, y).segment
yield from strip
continue
iter_segments = iter(strip)
if x < x1:
for segment in iter_segments:
next_x = x + _cell_len(segment.text)
if next_x > x1:
yield move_to(x, y).segment
yield segment
break
x = next_x
else:
yield move_to(x, y).segment
if end <= x2:
yield from iter_segments
else:
for segment in iter_segments:
if x >= x2:
break
yield segment
x += _cell_len(segment.text)
if y != last_y:
yield new_line
def render_segments(self, console: Console) -> str:
"""Render the update to raw data, suitable for writing to terminal.
Args:
console: Console instance.
Returns:
Raw data with escape sequences.
"""
sequences: list[str] = []
append = sequences.append
move_to = Control.move_to
chops = self.chops
chop_ends = self.chop_ends
last_y = self.spans[-1][0]
for y, x1, x2 in self.spans:
line = chops[y]
ends = chop_ends[y]
for end, (x, strip) in zip(ends, line.items()):
if strip is None:
continue
if x > x2 or end <= x1:
continue
if x2 > x >= x1 and end <= x2:
append(move_to(x, y).segment.text)
append(strip.render(console))
continue
strip = strip.crop(0, min(end, x2) - x)
append(move_to(x, y).segment.text)
append(strip.render(console))
if y != last_y:
append("\n")
terminal_sequences = "".join(sequences)
return terminal_sequences
def __rich_repr__(self) -> rich.repr.Result:
yield from ()
@rich.repr.auto(angular=True)
class Compositor:
"""Responsible for storing information regarding the relative positions of Widgets and rendering them."""
def __init__(self) -> None:
# A mapping of Widget on to its "render location" (absolute position / depth)
self._full_map: CompositorMap = {}
self._full_map_invalidated = True
self._visible_map: CompositorMap | None = None
self._layers: list[tuple[Widget, MapGeometry]] | None = None
# All widgets considered in the arrangement
# Note this may be a superset of self.full_map.keys() as some widgets may be invisible for various reasons
self.widgets: set[Widget] = set()
# Mapping of visible widgets on to their region, and clip region
self._visible_widgets: dict[Widget, tuple[Region, Region]] | None = None
# The top level widget
self.root: Widget | None = None
# Dimensions of the arrangement
self.size = Size(0, 0)
# The points in each line where the line bisects the left and right edges of the widget
self._cuts: list[list[int]] | None = None
# Regions that require an update
self._dirty_regions: set[Region] = set()
# Mapping of line numbers on to lists of widget and regions
self._layers_visible: list[list[tuple[Widget, Region, Region]]] | None = None
def clear(self) -> None:
"""Remove all references to widgets (used when the screen closes)."""
self._full_map.clear()
self._visible_map = None
self._layers = None
self.widgets.clear()
self._visible_widgets = None
self._layers_visible = None
@classmethod
def _regions_to_spans(
cls, regions: Iterable[Region]
) -> Iterable[tuple[int, int, int]]:
"""Converts the regions to horizontal spans. Spans will be combined if they overlap
or are contiguous to produce optimal non-overlapping spans.
Args:
regions: An iterable of Regions.
Returns:
Yields tuples of (Y, X1, X2).
"""
inline_ranges: dict[int, list[tuple[int, int]]] = {}
setdefault = inline_ranges.setdefault
for region_x, region_y, width, height in regions:
span = (region_x, region_x + width)
for y in range(region_y, region_y + height):
setdefault(y, []).append(span)
slice_remaining = slice(1, None)
for y, ranges in sorted(inline_ranges.items()):
if len(ranges) == 1:
# Special case of 1 span
yield (y, *ranges[0])
else:
ranges.sort()
x1, x2 = ranges[0]
for next_x1, next_x2 in ranges[slice_remaining]:
if next_x1 <= x2:
if next_x2 > x2:
x2 = next_x2
else:
yield (y, x1, x2)
x1 = next_x1
x2 = next_x2
yield (y, x1, x2)
def __rich_repr__(self) -> rich.repr.Result:
yield "size", self.size
yield "widgets", self.widgets
def reflow(self, parent: Widget, size: Size) -> ReflowResult:
"""Reflow (layout) widget and its children.
Args:
parent: The root widget.
size: Size of the area to be filled.
Returns:
Hidden, shown, and resized widgets.
"""
self._cuts = None
self._layers = None
self._layers_visible = None
self._visible_widgets = None
self._visible_map = None
self.root = parent
self.size = size
# Keep a copy of the old map because we're going to compare it with the update
old_map = self._full_map
old_widgets = old_map.keys()
map, widgets = self._arrange_root(parent, size, visible_only=False)
new_widgets = map.keys()
# Newly visible widgets
shown_widgets = new_widgets - old_widgets
# Newly hidden widgets
hidden_widgets = self.widgets - widgets
# Replace map and widgets
self._full_map = map
self.widgets = widgets
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
changes = map.items() ^ old_map.items()
# Widgets in both new and old
common_widgets = old_widgets & new_widgets
# Mark dirty regions.
screen_region = size.region
if screen_region not in self._dirty_regions:
regions = {
region
for region in (
map_geometry.clip.intersection(map_geometry.region)
for _, map_geometry in changes
)
if region
}
self._dirty_regions.update(regions)
resized_widgets = {
widget
for widget, (region, *_) in changes
if (widget in common_widgets and old_map[widget].region.size != region.size)
}
return ReflowResult(
hidden=hidden_widgets,
shown=shown_widgets,
resized=resized_widgets,
)
def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]:
"""Reflow only the visible children.
This is a fast-path for scrolling.
Args:
parent: The root widget.
size: Size of the area to be filled.
Returns:
Set of widgets that were exposed by the scroll.
"""
self._cuts = None
self._layers = None
self._layers_visible = None
self._visible_widgets = None
self._full_map_invalidated = True
self.root = parent
self.size = size
# Keep a copy of the old map because we're going to compare it with the update
old_map = self._visible_map or {}
map, widgets = self._arrange_root(parent, size, visible_only=True)
# Replace map and widgets
self._visible_map = map
self.widgets = widgets
exposed_widgets = map.keys() - old_map.keys()
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
changes = map.items() ^ old_map.items()
# Mark dirty regions.
screen_region = size.region
if screen_region not in self._dirty_regions:
regions = {
region
for region in (
map_geometry.clip.intersection(map_geometry.region)
for _, map_geometry in changes
)
if region
}
self._dirty_regions.update(regions)
return exposed_widgets
@property
def full_map(self) -> CompositorMap:
"""Lazily built compositor map that covers all widgets."""
if self.root is None:
return {}
if self._full_map_invalidated:
self._full_map_invalidated = False
map, _widgets = self._arrange_root(self.root, self.size, visible_only=False)
# Update any widgets which became visible in the interim
self._full_map = map
self._visible_widgets = None
self._visible_map = None
return self._full_map
@property
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
"""Get a mapping of widgets on to region and clip.
Returns:
Visible widget mapping.
"""
if self._visible_widgets is None:
map = (
self._visible_map
if self._visible_map is not None
else (self._full_map or {})
)
screen = self.size.region
in_screen = screen.overlaps
overlaps = Region.overlaps
# Widgets and regions in render order
visible_widgets = [
(order, widget, region, clip)
for widget, (region, order, clip, _, _, _, _) in map.items()
if in_screen(region) and overlaps(clip, region)
]
visible_widgets.sort(key=itemgetter(0), reverse=True)
self._visible_widgets = {
widget: (region, clip) for _, widget, region, clip in visible_widgets
}
return self._visible_widgets
def _arrange_root(
self, root: Widget, size: Size, visible_only: bool = True
) -> tuple[CompositorMap, set[Widget]]:
"""Arrange a widget's children based on its layout attribute.
Args:
root: Top level widget.
size: Size of visible area (screen).
visible_only: Only update visible widgets (used in scrolling).
Returns:
Compositor map and set of widgets.
"""
map: CompositorMap = {}
widgets: set[Widget] = set()
add_new_widget = widgets.add
invisible_widgets: set[Widget] = set()
add_new_invisible_widget = invisible_widgets.add
layer_order: int = 0
no_clip = size.region
def add_widget(
widget: Widget,
virtual_region: Region,
region: Region,
order: tuple[tuple[int, int, int], ...],
layer_order: int,
clip: Region,
visible: bool,
dock_gutter: Spacing,
_MapGeometry: type[MapGeometry] = MapGeometry,
) -> None:
"""Called recursively to place a widget and its children in the map.
Args:
widget: The widget to add.
virtual_region: The Widget region relative to its container.
region: The region the widget will occupy.
order: Painting order information.
layer_order: The order of the widget in its layer.
clip: The clipping region (i.e. the viewport which contains it).
visible: Whether the widget should be visible by default.
This may be overridden by the CSS rule `visibility`.
"""
if not widget._is_mounted:
return
styles = widget.styles
if (visibility := styles.get_rule("visibility")) is not None:
visible = visibility == "visible"
if visible:
add_new_widget(widget)
else:
add_new_invisible_widget(widget)
# Container region is minus border
container_region = region.shrink(styles.gutter)
container_size = container_region.size
# Widgets with scrollbars (containers or scroll view) require additional processing
if widget.is_scrollable:
# The region that contains the content (container region minus scrollbars)
child_region = (
container_region
if widget.loading
else widget._get_scrollable_region(container_region)
)
# The region covered by children relative to parent widget
total_region = child_region.reset_offset
if widget.is_container:
# Arrange the layout
arrange_result = widget.arrange(child_region.size)
arranged_widgets = arrange_result.widgets
widgets.update(arranged_widgets)
# Get the region that will be updated
sub_clip = clip.intersection(child_region)
if widget._anchored and not widget._anchor_released:
new_scroll_y = (
arrange_result.spatial_map.total_region.bottom
- (
widget.container_size.height
- widget.scrollbar_size_horizontal
)
)
widget.set_reactive(Widget.scroll_y, new_scroll_y)
widget.set_reactive(Widget.scroll_target_y, new_scroll_y)
widget.vertical_scrollbar._reactive_position = new_scroll_y
if visible_only:
placements = arrange_result.get_visible_placements(
sub_clip - child_region.offset + widget.scroll_offset
)
else:
placements = arrange_result.placements
total_region = total_region.union(arrange_result.total_region)
# An offset added to all placements
placement_offset = container_region.offset
placement_scroll_offset = placement_offset - widget.scroll_offset
placements = [
placement.process_offset(size.region, placement_scroll_offset)
for placement in placements
]
layers_to_index = {
layer_name: index
for index, layer_name in enumerate(widget.layers)
}
get_layer_index = layers_to_index.get
if widget._cover_widget is not None:
map[widget._cover_widget] = _MapGeometry(
region.shrink(widget.styles.gutter),
order,
clip,
region.size,
container_size,
virtual_region,
dock_gutter,
)
# Add all the widgets
for (
sub_region,
sub_region_offset,
_,
sub_widget,
z,
fixed,
overlay,
absolute,
) in reversed(placements):
layer_index = get_layer_index(sub_widget.layer, 0)
# Combine regions with children to calculate the "virtual size"
if fixed:
widget_region = (
sub_region + sub_region_offset + placement_offset
)
else:
widget_region = (
sub_region + sub_region_offset + placement_scroll_offset
)
widget_order = order + ((layer_index, z, layer_order),)
if widget._cover_widget is None:
add_widget(
sub_widget,
sub_region,
widget_region,
((1, 0, 0),) if overlay else widget_order,
layer_order,
no_clip if overlay else sub_clip,
visible,
arrange_result.scroll_spacing,
)
layer_order -= 1
else:
if widget._anchored and not widget._anchor_released:
new_scroll_y = widget.virtual_size.height - (
widget.container_size.height
- widget.scrollbar_size_horizontal
)
widget.scroll_y = new_scroll_y
widget.scroll_target_y = new_scroll_y
widget.vertical_scrollbar.position = new_scroll_y
if visible:
# Add any scrollbars
if (
widget.show_vertical_scrollbar
or widget.show_horizontal_scrollbar
) and styles.scrollbar_visibility == "visible":
for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region
):
map[chrome_widget] = _MapGeometry(
chrome_region,
order,
clip,
container_size,
container_size,
chrome_region,
dock_gutter,
)
map[widget._render_widget] = _MapGeometry(
region,
order,
clip,
total_region.size,
container_size,
virtual_region,
dock_gutter,
)
elif visible:
# Add the widget to the map
map[widget._render_widget] = _MapGeometry(
region,
order,
clip,
region.size,
container_size,
virtual_region,
dock_gutter,
)
# Add top level (root) widget
add_widget(
root,
size.region,
size.region,
((0, 0, 0),),
layer_order,
size.region,
True,
NULL_SPACING,
)
widgets -= invisible_widgets
return map, widgets
@property
def layers(self) -> list[tuple[Widget, MapGeometry]]:
"""Get widgets and geometry in layer order."""
map = self._visible_map if self._visible_map is not None else self._full_map
if self._layers is None:
self._layers = sorted(
map.items(), key=lambda item: item[1].order, reverse=True
)
return self._layers
@property
def layers_visible(self) -> list[list[tuple[Widget, Region, Region]]]:
"""Visible widgets and regions in layers order.
Returns:
Lists visible widgets per layer. Widgets are give as a tuple of
(WIDGET, CROPPED_REGION, REGION). CROPPED_REGION is clipped by
the container.
"""
if self._layers_visible is None:
layers_visible: list[list[tuple[Widget, Region, Region]]]
layers_visible = [[] for y in range(self.size.height)]
layers_visible_appends = [layer.append for layer in layers_visible]
intersection = Region.intersection
_range = range
for widget, (region, clip) in self.visible_widgets.items():
cropped_region = intersection(region, clip)
_x, region_y, _width, region_height = cropped_region
if region_height:
widget_location = (widget, cropped_region, region)
for y in _range(region_y, region_y + region_height):
layers_visible_appends[y](widget_location)
self._layers_visible = layers_visible
return self._layers_visible
def __contains__(self, widget: Widget) -> bool:
"""Check if the widget was included in the last update.
Args:
widget: A widget.
Returns:
`True` if the widget was in the last refresh, or `False` if it wasn't.
"""
# Try to avoid a recalculation of full_map if possible.
return (
widget in self.widgets
or (self._visible_map is not None and widget in self._visible_map)
or widget in self.full_map
)
def get_offset(self, widget: Widget) -> Offset:
"""Get the offset of a widget.
Args:
widget: Widget to query.
Returns:
Offset of widget.
"""
try:
if self._visible_map is not None:
try:
return self._visible_map[widget].region.offset
except KeyError:
pass
return self.full_map[widget].region.offset
except KeyError:
raise errors.NoWidget("Widget is not in layout")
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under a given coordinate.
Args:
x: X Coordinate.
y: Y Coordinate.
Raises:
errors.NoWidget: If there is not widget underneath (x, y).
Returns:
A tuple of the widget and its region.
"""
contains = Region.contains
if len(self.layers_visible) > y >= 0:
for widget, cropped_region, region in self.layers_visible[int(y)]:
if contains(cropped_region, x, y) and widget.visible:
return widget, region
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")
def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
"""Get all widgets under a given coordinate.
Args:
x: X coordinate.
y: Y coordinate.
Returns:
Sequence of (WIDGET, REGION) tuples.
"""
contains = Region.contains
if len(self.layers_visible) > y >= 0:
for widget, cropped_region, region in self.layers_visible[y]:
if contains(cropped_region, x, y) and widget.visible:
yield widget, region
def get_style_at(self, x: int, y: int) -> Style:
"""Get the Style at the given cell or Style.null()
Args:
x: X position within the Layout.
y: Y position within the Layout.
Returns:
The Style at the cell (x, y) within the Layout.
"""
try:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return Style.null()
if widget not in self.visible_widgets:
return Style.null()
x -= region.x
y -= region.y
visible_screen_stack.set(widget.app._background_screens)
lines = widget.render_lines(Region(0, y, region.width, 1))
if not lines:
return Style.null()
end = 0
for segment in lines[0]:
end += segment.cell_length
if x < end:
return segment.style or Style.null()
return Style.null()
def get_widget_and_offset_at(
self, x: int, y: int
) -> tuple[Widget | None, Offset | None]:
"""Get the Style at the given cell, the offset within the content.
Args:
x: X position within the Layout.
y: Y position within the Layout.
Returns:
A tuple of the widget at (x, y) and the offset within the widget.
"""
try:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return None, None
if widget not in self.visible_widgets:
return None, None
if y >= widget.content_region.bottom:
x, y = widget.content_region.bottom_right_inclusive
gutter_left, gutter_right = widget.gutter.top_left
x -= region.x + gutter_left
y -= region.y + gutter_right
if y < 0:
return None, None
visible_screen_stack.set(widget.app._background_screens)
line = widget.render_line(y)
end = 0
start = 0
offset_y: int | None = None
offset_x = 0
offset_x2 = 0
from rich.cells import get_character_cell_size
for segment in line:
end += segment.cell_length
style = segment.style
if style is not None and style._meta is not None:
meta = style.meta
if "offset" in meta:
offset_x, offset_y = style.meta["offset"]
offset_x2 = offset_x + len(segment.text)
if x < end and x >= start:
segment_cell_length = 0
cell_cut = x - start
segment_offset = 0
for character in segment.text:
if segment_cell_length >= cell_cut:
break
segment_cell_length += get_character_cell_size(character)
segment_offset += 1
return widget, (
None
if offset_y is None
else Offset(offset_x + segment_offset, offset_y)
)
start = end
return widget, (None if offset_y is None else Offset(offset_x2, offset_y))
def find_widget(self, widget: Widget) -> MapGeometry:
"""Get information regarding the relative position of a widget in the Compositor.
Args:
widget: The Widget in this layout you wish to know the Region of.
Raises:
NoWidget: If the Widget is not contained in this Layout.
Returns:
Widget's composition information.
"""
if self.root is None:
raise errors.NoWidget("Widget is not in layout")
try:
if not self._full_map_invalidated:
try:
return self._full_map[widget]
except KeyError:
pass
if self._visible_map is not None:
try:
return self._visible_map[widget]
except KeyError:
pass
region = self.full_map[widget]
except KeyError:
raise errors.NoWidget("Widget is not in layout")
else:
return region
@property
def cuts(self) -> list[list[int]]:
"""Get vertical cuts.
A cut is every point on a line where a widget starts or ends.
Returns:
A list of cuts for every line.
"""
if self._cuts is not None:
return self._cuts
width, height = self.size
cuts = [[0, width] for _ in range(height)]
intersection = Region.intersection
extend = list.extend
for region, clip in self.visible_widgets.values():
x, y, region_width, region_height = intersection(region, clip)
if region_width and region_height:
region_cuts = (x, x + region_width)
for cut in cuts[y : y + region_height]:
extend(cut, region_cuts)
# Sort the cuts for each line
self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts]
return self._cuts
def _get_renders(
self, crop: Region | None = None
) -> Iterable[tuple[Region, Region, list[Strip]]]:
"""Get rendered widgets (lists of segments) in the composition.
Args:
crop: Region to crop to, or `None` for entire screen.
Returns:
An iterable of <region>, <clip region>, and <strips>
"""
# If a renderable throws an error while rendering, the user likely doesn't care about the traceback
# up to this point.
_rich_traceback_guard = True
_Region = Region
visible_widgets = self.visible_widgets
if crop:
crop_overlaps = crop.overlaps
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
if crop_overlaps(clip)
]
else:
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
]
intersection = _Region.intersection
contains_region = _Region.contains_region
for widget, region, clip in widget_regions:
if contains_region(clip, region):
yield (
region,
clip,
widget.render_lines(
_Region(
0,
0,
region.width,
region.height,
)
),
)
else:
new_x, new_y, new_width, new_height = intersection(region, clip)
if new_width and new_height:
yield (
region,
clip,
widget.render_lines(
_Region(
new_x - region.x,
new_y - region.y,
new_width,
new_height,
)
),
)
def render_update(
self,
full: bool = False,
screen_stack: list[Screen] | None = None,
simplify: bool = False,
) -> RenderableType | None:
"""Render an update renderable.
Args:
full: Perform a full update if `True`, otherwise a partial update.
screen_stack: Screen stack list. Defaults to None.
simplify: Simplify segments.
Returns:
A renderable for the update, or `None` if no update was required.
"""
visible_screen_stack.set([] if screen_stack is None else screen_stack)
screen_region = self.size.region
if full or screen_region in self._dirty_regions:
return self.render_full_update(simplify=simplify)
else:
return self.render_partial_update()
def render_inline(
self,
size: Size,
screen_stack: list[Screen] | None = None,
clear: bool = False,
) -> RenderableType:
"""Render an inline update.
Args:
size: Inline size.
screen_stack: Screen stack list. Defaults to None.
clear: Also clear below the inline update (set when size decreases).
Returns:
A renderable.
"""
visible_screen_stack.set([] if screen_stack is None else screen_stack)
strips = self.render_strips(size)
return InlineUpdate(strips, clear=clear)
def render_full_update(self, simplify: bool = False) -> LayoutUpdate:
"""Render a full update.
Args:
simplify: Simplify the segments (combine contiguous segments).
Returns:
A LayoutUpdate renderable.
"""
screen_region = self.size.region
self._dirty_regions.clear()
crop = screen_region
chops = self._render_chops(crop, lambda y: True)
render_strips: list[Iterable[Strip]]
if simplify:
# Simplify is done when exporting to SVG
# It doesn't make things faster
render_strips = [
[Strip.join(chop.values()).simplify().discard_meta()] for chop in chops
]
else:
render_strips = [chop.values() for chop in chops]
return LayoutUpdate(render_strips, screen_region)
def render_partial_update(self) -> ChopsUpdate | None:
"""Render a partial update.
Returns:
A ChopsUpdate if there is anything to update, otherwise `None`.
"""
screen_region = self.size.region
update_regions = self._dirty_regions.copy()
self._dirty_regions.clear()
if update_regions:
# Create a crop region that surrounds all updates.
crop = Region.from_union(update_regions).intersection(screen_region)
spans = list(self._regions_to_spans(update_regions))
is_rendered_line = {y for y, _, _ in spans}.__contains__
else:
return None
chops = self._render_chops(crop, is_rendered_line)
chop_ends = [cut_set[1:] for cut_set in self.cuts]
return ChopsUpdate(chops, spans, chop_ends)
def render_strips(self, size: Size | None = None) -> list[Strip]:
"""Render to a list of strips.
Args:
size: Size of render.
Returns:
A list of strips with the screen content.
"""
if size is None:
size = self.size
chops = self._render_chops(size.region, lambda y: True)
render_strips = [Strip.join(chop.values()) for chop in chops[: size.height]]
return render_strips
def _render_chops(
self,
crop: Region,
is_rendered_line: Callable[[int], bool],
) -> Sequence[Mapping[int, Strip]]:
"""Render update 'chops'.
Args:
crop: Region to crop to.
is_rendered_line: Callable to check if line should be rendered.
Returns:
Chops structure.
"""
cuts = self.cuts
fromkeys = cast("Callable[[list[int]], dict[int, Strip | None]]", dict.fromkeys)
chops: list[dict[int, Strip | None]]
chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]
cut_strips: Iterable[Strip]
# Go through all the renders in reverse order and fill buckets with no render
renders = self._get_renders(crop)
intersection = Region.intersection
for region, clip, strips in renders:
render_region = intersection(region, clip)
render_x = render_region.x
first_cut, last_cut = render_region.column_span
for y, strip in zip(render_region.line_range, strips):
if not is_rendered_line(y):
continue
chops_line = chops[y]
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
cut_strips = strip.divide([cut - render_x for cut in final_cuts[1:]])
# Since we are painting front to back, the first segments for a cut "wins"
get_chops_line = chops_line.get
for cut, strip in zip(final_cuts, cut_strips):
if get_chops_line(cut) is None:
chops_line[cut] = strip
return cast("Sequence[Mapping[int, Strip]]", chops)
def __rich__(self) -> StripRenderable:
return StripRenderable(self.render_strips())
def update_widgets(self, widgets: set[Widget]) -> None:
"""Update the given widgets in the composition.
Args:
widgets: Set of Widgets to update.
"""
# If there are any *new* widgets we need to invalidate the full map
if not self._full_map_invalidated and not widgets.issubset(
self.visible_widgets.keys()
):
self._full_map_invalidated = True
regions: list[Region] = []
add_region = regions.append
get_widget = self.visible_widgets.__getitem__
for widget in self.visible_widgets.keys() & widgets:
region, clip = get_widget(widget)
offset = region.offset
intersection = clip.intersection
for dirty_region in widget._exchange_repaint_regions():
if update_region := intersection(dirty_region.translate(offset)):
add_region(update_region)
self._dirty_regions.update(regions)