improved compositor granularity

This commit is contained in:
Will McGugan
2022-05-10 10:30:16 +01:00
parent 231ad797d7
commit 20c3220d73
8 changed files with 174 additions and 93 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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)]

View File

@@ -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)