mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
improved compositor granularity
This commit is contained in:
@@ -18,7 +18,7 @@ import sys
|
||||
from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
|
||||
|
||||
import rich.repr
|
||||
from rich.console import Console, ConsoleOptions, RenderResult
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
from rich.control import Control
|
||||
from rich.segment import Segment, SegmentLines
|
||||
from rich.style import Style
|
||||
@@ -91,6 +91,23 @@ class LayoutUpdate:
|
||||
yield "height", height
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class SpansUpdate:
|
||||
def __init__(self, spans: list[tuple[int, int, list[Segment]]]) -> None:
|
||||
self.spans = spans
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
move_to = Control.move_to
|
||||
for line, offset, segments in self.spans:
|
||||
yield move_to(offset, line)
|
||||
yield from segments
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.spans
|
||||
|
||||
|
||||
@rich.repr.auto(angular=True)
|
||||
class Compositor:
|
||||
"""Responsible for storing information regarding the relative positions of Widgets and rendering them."""
|
||||
@@ -116,6 +133,43 @@ class Compositor:
|
||||
# The points in each line where the line bisects the left and right edges of the widget
|
||||
self._cuts: list[list[int]] | None = None
|
||||
|
||||
@classmethod
|
||||
def _regions_to_spans(
|
||||
cls, regions: Iterable[Region]
|
||||
) -> Iterable[tuple[int, int, int]]:
|
||||
"""Converts the regions to non-overlapping horizontal spans, where each span
|
||||
represents the region on a single line. Combining the resulting strips therefore
|
||||
results in a shape identical to the combined original regions.
|
||||
|
||||
Args:
|
||||
regions (Iterable[Region]): An iterable of Regions.
|
||||
|
||||
Returns:
|
||||
Iterable[tuple[int, int, int]]: Yields tuples of (Y, X1, X2)
|
||||
"""
|
||||
inline_ranges: dict[int, list[tuple[int, int]]] = {}
|
||||
for region_x, region_y, width, height in regions:
|
||||
span = (region_x, region_x + width - 1)
|
||||
for y in range(region_y, region_y + height):
|
||||
inline_ranges.setdefault(y, []).append(span)
|
||||
|
||||
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[1:]:
|
||||
if next_x1 <= x2 + 1:
|
||||
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
|
||||
@@ -452,11 +506,7 @@ class Compositor:
|
||||
]
|
||||
return segment_lines
|
||||
|
||||
def render(
|
||||
self,
|
||||
*,
|
||||
crop: Region | None = None,
|
||||
) -> SegmentLines:
|
||||
def render(self, regions: list[Region] | None = None) -> RenderableType:
|
||||
"""Render a layout.
|
||||
|
||||
Args:
|
||||
@@ -467,8 +517,13 @@ class Compositor:
|
||||
"""
|
||||
width, height = self.size
|
||||
screen_region = Region(0, 0, width, height)
|
||||
|
||||
crop_region = crop.intersection(screen_region) if crop else screen_region
|
||||
if regions:
|
||||
crop = Region.from_union(regions).intersection(screen_region)
|
||||
spans = list(self._regions_to_spans(regions))
|
||||
is_rendered_line = {y for y, _, _ in spans}.__contains__
|
||||
else:
|
||||
crop = screen_region
|
||||
is_rendered_line = lambda y: True
|
||||
|
||||
_Segment = Segment
|
||||
divide = _Segment.divide
|
||||
@@ -492,6 +547,8 @@ class Compositor:
|
||||
render_region = intersection(region, clip)
|
||||
|
||||
for y, line in zip(render_region.y_range, lines):
|
||||
if not is_rendered_line(y):
|
||||
continue
|
||||
first_cut, last_cut = render_region.x_extents
|
||||
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
||||
|
||||
@@ -510,9 +567,18 @@ class Compositor:
|
||||
chops_line[cut] = segments
|
||||
|
||||
# Assemble the cut renders in to lists of segments
|
||||
crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners
|
||||
crop_x, crop_y, crop_x2, crop_y2 = crop.corners
|
||||
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
|
||||
|
||||
if not regions:
|
||||
return SegmentLines(render_lines, new_lines=True)
|
||||
|
||||
print("SPANS", spans)
|
||||
render_spans = [
|
||||
(y, x1, line_crop(render_lines[y - crop_y], x1, x2)) for y, x1, x2 in spans
|
||||
]
|
||||
return SpansUpdate(render_spans)
|
||||
|
||||
if crop is not None and (crop_x, crop_x2) != (0, width):
|
||||
render_lines = [
|
||||
line_crop(line, crop_x, crop_x2) if line else line
|
||||
@@ -526,7 +592,7 @@ class Compositor:
|
||||
) -> RenderResult:
|
||||
yield self.render()
|
||||
|
||||
def update_widget(self, console: Console, widget: Widget) -> LayoutUpdate | None:
|
||||
def update_widgets(self, *widgets: Widget) -> RenderableType | None:
|
||||
"""Update a given widget in the composition.
|
||||
|
||||
Args:
|
||||
@@ -536,14 +602,24 @@ class Compositor:
|
||||
Returns:
|
||||
LayoutUpdate | None: A renderable or None if nothing to render.
|
||||
"""
|
||||
if widget not in self.regions:
|
||||
return None
|
||||
region, clip = self.regions[widget]
|
||||
if not region:
|
||||
return None
|
||||
update_region = region.intersection(clip)
|
||||
if not update_region:
|
||||
return None
|
||||
update_lines = self.render(crop=update_region).lines
|
||||
update = LayoutUpdate(update_lines, update_region)
|
||||
log(widgets)
|
||||
regions: list[Region] = []
|
||||
add_region = regions.append
|
||||
for widget in widgets:
|
||||
if widget not in self.regions:
|
||||
continue
|
||||
region, clip = self.regions[widget]
|
||||
if not region:
|
||||
continue
|
||||
update_region = region.intersection(clip)
|
||||
if not update_region:
|
||||
continue
|
||||
add_region(update_region)
|
||||
|
||||
print(regions)
|
||||
update = self.render(regions or None)
|
||||
print("UPDATE", update)
|
||||
return update
|
||||
# update = LayoutUpdate(update_lines, total_region)
|
||||
# # print(widgets, total_region)
|
||||
# return update
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from operator import attrgetter
|
||||
from typing import NamedTuple, Iterable
|
||||
|
||||
from .geometry import Region
|
||||
|
||||
|
||||
class InlineRange(NamedTuple):
|
||||
"""Represents a region on a single line."""
|
||||
|
||||
line_index: int
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
def regions_to_ranges(regions: Iterable[Region]) -> Iterable[InlineRange]:
|
||||
"""Converts the regions to non-overlapping horizontal strips, where each strip
|
||||
represents the region on a single line. Combining the resulting strips therefore
|
||||
results in a shape identical to the combined original regions.
|
||||
|
||||
Args:
|
||||
regions (Iterable[Region]): An iterable of Regions.
|
||||
|
||||
Returns:
|
||||
Iterable[InlineRange]: Yields InlineRange objects representing the content on
|
||||
a single line, with overlaps removed.
|
||||
"""
|
||||
inline_ranges: dict[int, list[InlineRange]] = defaultdict(list)
|
||||
for region_x, region_y, width, height in regions:
|
||||
for y in range(region_y, region_y + height):
|
||||
inline_ranges[y].append(
|
||||
InlineRange(line_index=y, start=region_x, end=region_x + width - 1)
|
||||
)
|
||||
|
||||
get_start = attrgetter("start")
|
||||
for line_index, ranges in inline_ranges.items():
|
||||
sorted_ranges = iter(sorted(ranges, key=get_start))
|
||||
_, start, end = next(sorted_ranges)
|
||||
for next_line_index, next_start, next_end in sorted_ranges:
|
||||
if next_start <= end + 1:
|
||||
end = max(end, next_end)
|
||||
else:
|
||||
yield InlineRange(line_index, start, end)
|
||||
start = next_start
|
||||
end = next_end
|
||||
yield InlineRange(line_index, start, end)
|
||||
@@ -615,6 +615,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
is_renderable(renderable) for renderable in renderables
|
||||
), "Can only call panic with strings or Rich renderables"
|
||||
|
||||
self.console.bell()
|
||||
prerendered = [
|
||||
Segments(self.console.render(renderable, self.console.options))
|
||||
for renderable in renderables
|
||||
|
||||
@@ -6,7 +6,7 @@ Functions and classes to manage terminal geometry (anything involving coordinate
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
|
||||
from typing import Any, cast, Iterable, NamedTuple, Tuple, Union, TypeVar
|
||||
|
||||
SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
|
||||
|
||||
@@ -181,6 +181,24 @@ class Region(NamedTuple):
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_union(cls, regions: list[Region]) -> Region:
|
||||
"""Create a Region from the union of other regions.
|
||||
|
||||
Args:
|
||||
regions (Iterable[Region]): One or more regions.
|
||||
|
||||
Returns:
|
||||
Region: A Region that encloses all other regions.
|
||||
"""
|
||||
if not regions:
|
||||
raise ValueError("At least one region expected")
|
||||
min_x = min([region.x for region in regions])
|
||||
max_x = max([x + width for x, _y, width, _height in regions])
|
||||
min_y = min([region.y for region in regions])
|
||||
max_y = max([y + height for _x, y, _width, height in regions])
|
||||
return cls(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||
|
||||
@classmethod
|
||||
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
|
||||
"""Construct a Region form the top left and bottom right corners.
|
||||
|
||||
@@ -149,8 +149,11 @@ class MessagePump:
|
||||
callback: TimerCallback = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
pause: bool = False,
|
||||
) -> Timer:
|
||||
timer = Timer(self, delay, self, name=name, callback=callback, repeat=0)
|
||||
timer = Timer(
|
||||
self, delay, self, name=name, callback=callback, repeat=0, pause=pause
|
||||
)
|
||||
self._child_tasks.add(timer.start())
|
||||
return timer
|
||||
|
||||
@@ -161,9 +164,16 @@ class MessagePump:
|
||||
*,
|
||||
name: str | None = None,
|
||||
repeat: int = 0,
|
||||
pause: bool = False,
|
||||
):
|
||||
timer = Timer(
|
||||
self, interval, self, name=name, callback=callback, repeat=repeat or None
|
||||
self,
|
||||
interval,
|
||||
self,
|
||||
name=name,
|
||||
callback=callback,
|
||||
repeat=repeat or None,
|
||||
pause=pause,
|
||||
)
|
||||
self._child_tasks.add(timer.start())
|
||||
return timer
|
||||
|
||||
@@ -9,6 +9,7 @@ from . import events, messages, errors
|
||||
|
||||
from .geometry import Offset, Region
|
||||
from ._compositor import Compositor
|
||||
from ._timer import Timer
|
||||
from .reactive import Reactive
|
||||
from .widget import Widget
|
||||
|
||||
@@ -33,7 +34,7 @@ class Screen(Widget):
|
||||
def __init__(self, name: str | None = None, id: str | None = None) -> None:
|
||||
super().__init__(name=name, id=id)
|
||||
self._compositor = Compositor()
|
||||
self._dirty_widgets: list[Widget] = []
|
||||
self._dirty_widgets: set[Widget] = set()
|
||||
|
||||
def watch_dark(self, dark: bool) -> None:
|
||||
pass
|
||||
@@ -90,14 +91,19 @@ class Screen(Widget):
|
||||
def on_idle(self, event: events.Idle) -> None:
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
if self._dirty_widgets:
|
||||
for widget in self._dirty_widgets:
|
||||
# Repaint widgets
|
||||
# TODO: Combine these in to a single update.
|
||||
display_update = self._compositor.update_widget(self.console, widget)
|
||||
if display_update is not None:
|
||||
self.app.display(display_update)
|
||||
# Reset dirty list
|
||||
self._update_timer.resume()
|
||||
|
||||
def _on_update(self) -> None:
|
||||
"""Called by the _update_timer."""
|
||||
|
||||
# Render widgets together
|
||||
if self._dirty_widgets:
|
||||
self.log(dirty=len(self._dirty_widgets))
|
||||
display_update = self._compositor.update_widgets(*self._dirty_widgets)
|
||||
if display_update is not None:
|
||||
self.app.display(display_update)
|
||||
self._dirty_widgets.clear()
|
||||
self._update_timer.pause()
|
||||
|
||||
def refresh_layout(self) -> None:
|
||||
"""Refresh the layout (can change size and positions of widgets)."""
|
||||
@@ -139,13 +145,16 @@ class Screen(Widget):
|
||||
message.stop()
|
||||
widget = message.widget
|
||||
assert isinstance(widget, Widget)
|
||||
self._dirty_widgets.append(widget)
|
||||
self._dirty_widgets.add(widget)
|
||||
self.check_idle()
|
||||
|
||||
async def handle_layout(self, message: messages.Layout) -> None:
|
||||
message.stop()
|
||||
self.refresh_layout()
|
||||
|
||||
def on_mount(self, event: events.Mount) -> None:
|
||||
self._update_timer = self.set_interval(1 / 20, self._on_update, pause=True)
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
self.size_updated(event.size, event.virtual_size, event.container_size)
|
||||
self.refresh_layout()
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
from textual._region_group import regions_to_ranges, InlineRange
|
||||
from textual._compositor import Compositor
|
||||
from textual.geometry import Region
|
||||
|
||||
|
||||
def test_regions_to_ranges_no_regions():
|
||||
assert list(regions_to_ranges([])) == []
|
||||
assert list(Compositor._regions_to_spans([])) == []
|
||||
|
||||
|
||||
def test_regions_to_ranges_single_region():
|
||||
regions = [Region(0, 0, 3, 2)]
|
||||
assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 2), InlineRange(1, 0, 2)]
|
||||
assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 2), (1, 0, 2)]
|
||||
|
||||
|
||||
def test_regions_to_ranges_partially_overlapping_regions():
|
||||
regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
|
||||
assert list(regions_to_ranges(regions)) == [
|
||||
InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2),
|
||||
assert list(Compositor._regions_to_spans(regions)) == [
|
||||
(0, 0, 1),
|
||||
(1, 0, 2),
|
||||
(2, 1, 2),
|
||||
]
|
||||
|
||||
|
||||
def test_regions_to_ranges_fully_overlapping_regions():
|
||||
regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
|
||||
assert list(regions_to_ranges(regions)) == [
|
||||
InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3)
|
||||
assert list(Compositor._regions_to_spans(regions)) == [
|
||||
(1, 1, 3),
|
||||
(2, 0, 3),
|
||||
(3, 1, 3),
|
||||
]
|
||||
|
||||
|
||||
def test_regions_to_ranges_disjoint_regions_different_lines():
|
||||
regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)]
|
||||
assert list(regions_to_ranges(regions)) == [InlineRange(0, 0, 1), InlineRange(2, 2, 3)]
|
||||
assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 1), (2, 2, 3)]
|
||||
|
||||
|
||||
def test_regions_to_ranges_disjoint_regions_same_line():
|
||||
regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
|
||||
assert list(regions_to_ranges(regions)) == [
|
||||
InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0)
|
||||
assert list(Compositor._regions_to_spans(regions)) == [
|
||||
(0, 0, 0),
|
||||
(0, 2, 2),
|
||||
(1, 0, 0),
|
||||
]
|
||||
|
||||
|
||||
def test_regions_to_ranges_directly_adjacent_ranges_merged():
|
||||
regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
|
||||
assert list(regions_to_ranges(regions)) == [
|
||||
InlineRange(0, 0, 1), InlineRange(1, 0, 1)
|
||||
]
|
||||
assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 1), (1, 0, 1)]
|
||||
@@ -114,6 +114,17 @@ def test_region_null():
|
||||
assert not Region()
|
||||
|
||||
|
||||
def test_region_from_union():
|
||||
with pytest.raises(ValueError):
|
||||
Region.from_union([])
|
||||
regions = [
|
||||
Region(10, 20, 30, 40),
|
||||
Region(15, 25, 5, 5),
|
||||
Region(30, 25, 20, 10),
|
||||
]
|
||||
assert Region.from_union(regions) == Region(10, 20, 40, 40)
|
||||
|
||||
|
||||
def test_region_from_origin():
|
||||
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user