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 from typing import cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING
import rich.repr 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.control import Control
from rich.segment import Segment, SegmentLines from rich.segment import Segment, SegmentLines
from rich.style import Style from rich.style import Style
@@ -91,6 +91,23 @@ class LayoutUpdate:
yield "height", height 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) @rich.repr.auto(angular=True)
class Compositor: class Compositor:
"""Responsible for storing information regarding the relative positions of Widgets and rendering them.""" """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 # The points in each line where the line bisects the left and right edges of the widget
self._cuts: list[list[int]] | None = None 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: def __rich_repr__(self) -> rich.repr.Result:
yield "size", self.size yield "size", self.size
yield "widgets", self.widgets yield "widgets", self.widgets
@@ -452,11 +506,7 @@ class Compositor:
] ]
return segment_lines return segment_lines
def render( def render(self, regions: list[Region] | None = None) -> RenderableType:
self,
*,
crop: Region | None = None,
) -> SegmentLines:
"""Render a layout. """Render a layout.
Args: Args:
@@ -467,8 +517,13 @@ class Compositor:
""" """
width, height = self.size width, height = self.size
screen_region = Region(0, 0, width, height) screen_region = Region(0, 0, width, height)
if regions:
crop_region = crop.intersection(screen_region) if crop else screen_region 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 _Segment = Segment
divide = _Segment.divide divide = _Segment.divide
@@ -492,6 +547,8 @@ class Compositor:
render_region = intersection(region, clip) render_region = intersection(region, clip)
for y, line in zip(render_region.y_range, lines): for y, line in zip(render_region.y_range, lines):
if not is_rendered_line(y):
continue
first_cut, last_cut = render_region.x_extents first_cut, last_cut = render_region.x_extents
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
@@ -510,9 +567,18 @@ class Compositor:
chops_line[cut] = segments chops_line[cut] = segments
# Assemble the cut renders in to lists of 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]) 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): if crop is not None and (crop_x, crop_x2) != (0, width):
render_lines = [ render_lines = [
line_crop(line, crop_x, crop_x2) if line else line line_crop(line, crop_x, crop_x2) if line else line
@@ -526,7 +592,7 @@ class Compositor:
) -> RenderResult: ) -> RenderResult:
yield self.render() 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. """Update a given widget in the composition.
Args: Args:
@@ -536,14 +602,24 @@ class Compositor:
Returns: Returns:
LayoutUpdate | None: A renderable or None if nothing to render. LayoutUpdate | None: A renderable or None if nothing to render.
""" """
if widget not in self.regions: log(widgets)
return None regions: list[Region] = []
region, clip = self.regions[widget] add_region = regions.append
if not region: for widget in widgets:
return None if widget not in self.regions:
update_region = region.intersection(clip) continue
if not update_region: region, clip = self.regions[widget]
return None if not region:
update_lines = self.render(crop=update_region).lines continue
update = LayoutUpdate(update_lines, update_region) 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 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 is_renderable(renderable) for renderable in renderables
), "Can only call panic with strings or Rich renderables" ), "Can only call panic with strings or Rich renderables"
self.console.bell()
prerendered = [ prerendered = [
Segments(self.console.render(renderable, self.console.options)) Segments(self.console.render(renderable, self.console.options))
for renderable in renderables 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 __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]] SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
@@ -181,6 +181,24 @@ class Region(NamedTuple):
width: int = 0 width: int = 0
height: 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 @classmethod
def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region: def from_corners(cls, x1: int, y1: int, x2: int, y2: int) -> Region:
"""Construct a Region form the top left and bottom right corners. """Construct a Region form the top left and bottom right corners.

View File

@@ -149,8 +149,11 @@ class MessagePump:
callback: TimerCallback = None, callback: TimerCallback = None,
*, *,
name: str | None = None, name: str | None = None,
pause: bool = False,
) -> Timer: ) -> 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()) self._child_tasks.add(timer.start())
return timer return timer
@@ -161,9 +164,16 @@ class MessagePump:
*, *,
name: str | None = None, name: str | None = None,
repeat: int = 0, repeat: int = 0,
pause: bool = False,
): ):
timer = Timer( 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()) self._child_tasks.add(timer.start())
return timer return timer

View File

@@ -9,6 +9,7 @@ from . import events, messages, errors
from .geometry import Offset, Region from .geometry import Offset, Region
from ._compositor import Compositor from ._compositor import Compositor
from ._timer import Timer
from .reactive import Reactive from .reactive import Reactive
from .widget import Widget from .widget import Widget
@@ -33,7 +34,7 @@ class Screen(Widget):
def __init__(self, name: str | None = None, id: str | None = None) -> None: def __init__(self, name: str | None = None, id: str | None = None) -> None:
super().__init__(name=name, id=id) super().__init__(name=name, id=id)
self._compositor = Compositor() self._compositor = Compositor()
self._dirty_widgets: list[Widget] = [] self._dirty_widgets: set[Widget] = set()
def watch_dark(self, dark: bool) -> None: def watch_dark(self, dark: bool) -> None:
pass pass
@@ -90,14 +91,19 @@ class Screen(Widget):
def on_idle(self, event: events.Idle) -> None: def on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint) # Check for any widgets marked as 'dirty' (needs a repaint)
if self._dirty_widgets: if self._dirty_widgets:
for widget in self._dirty_widgets: self._update_timer.resume()
# Repaint widgets
# TODO: Combine these in to a single update. def _on_update(self) -> None:
display_update = self._compositor.update_widget(self.console, widget) """Called by the _update_timer."""
if display_update is not None:
self.app.display(display_update) # Render widgets together
# Reset dirty list 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._dirty_widgets.clear()
self._update_timer.pause()
def refresh_layout(self) -> None: def refresh_layout(self) -> None:
"""Refresh the layout (can change size and positions of widgets).""" """Refresh the layout (can change size and positions of widgets)."""
@@ -139,13 +145,16 @@ class Screen(Widget):
message.stop() message.stop()
widget = message.widget widget = message.widget
assert isinstance(widget, Widget) assert isinstance(widget, Widget)
self._dirty_widgets.append(widget) self._dirty_widgets.add(widget)
self.check_idle() self.check_idle()
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
message.stop() message.stop()
self.refresh_layout() 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: async def on_resize(self, event: events.Resize) -> None:
self.size_updated(event.size, event.virtual_size, event.container_size) self.size_updated(event.size, event.virtual_size, event.container_size)
self.refresh_layout() 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 from textual.geometry import Region
def test_regions_to_ranges_no_regions(): 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(): def test_regions_to_ranges_single_region():
regions = [Region(0, 0, 3, 2)] 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(): def test_regions_to_ranges_partially_overlapping_regions():
regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)] regions = [Region(0, 0, 2, 2), Region(1, 1, 2, 2)]
assert list(regions_to_ranges(regions)) == [ assert list(Compositor._regions_to_spans(regions)) == [
InlineRange(0, 0, 1), InlineRange(1, 0, 2), InlineRange(2, 1, 2), (0, 0, 1),
(1, 0, 2),
(2, 1, 2),
] ]
def test_regions_to_ranges_fully_overlapping_regions(): def test_regions_to_ranges_fully_overlapping_regions():
regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)] regions = [Region(1, 1, 3, 3), Region(2, 2, 1, 1), Region(0, 2, 3, 1)]
assert list(regions_to_ranges(regions)) == [ assert list(Compositor._regions_to_spans(regions)) == [
InlineRange(1, 1, 3), InlineRange(2, 0, 3), InlineRange(3, 1, 3) (1, 1, 3),
(2, 0, 3),
(3, 1, 3),
] ]
def test_regions_to_ranges_disjoint_regions_different_lines(): def test_regions_to_ranges_disjoint_regions_different_lines():
regions = [Region(0, 0, 2, 1), Region(2, 2, 2, 1)] 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(): def test_regions_to_ranges_disjoint_regions_same_line():
regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)] regions = [Region(0, 0, 1, 2), Region(2, 0, 1, 1)]
assert list(regions_to_ranges(regions)) == [ assert list(Compositor._regions_to_spans(regions)) == [
InlineRange(0, 0, 0), InlineRange(0, 2, 2), InlineRange(1, 0, 0) (0, 0, 0),
(0, 2, 2),
(1, 0, 0),
] ]
def test_regions_to_ranges_directly_adjacent_ranges_merged(): def test_regions_to_ranges_directly_adjacent_ranges_merged():
regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)] regions = [Region(0, 0, 1, 2), Region(1, 0, 1, 2)]
assert list(regions_to_ranges(regions)) == [ assert list(Compositor._regions_to_spans(regions)) == [(0, 0, 1), (1, 0, 1)]
InlineRange(0, 0, 1), InlineRange(1, 0, 1)
]

View File

@@ -114,6 +114,17 @@ def test_region_null():
assert not Region() 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(): def test_region_from_origin():
assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6) assert Region.from_origin(Offset(3, 4), (5, 6)) == Region(3, 4, 5, 6)