diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1b1b0ed0b..b15a65273 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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 diff --git a/src/textual/_region_group.py b/src/textual/_region_group.py deleted file mode 100644 index 096007987..000000000 --- a/src/textual/_region_group.py +++ /dev/null @@ -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) diff --git a/src/textual/app.py b/src/textual/app.py index 63c6cfa7c..458897d80 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 324707178..44ce1d5d1 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -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. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 66ed98721..9fefc5ec3 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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 diff --git a/src/textual/screen.py b/src/textual/screen.py index a575018da..7d36e8edf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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() diff --git a/tests/test_region_group.py b/tests/test_compositor_regions_to_spans.py similarity index 50% rename from tests/test_region_group.py rename to tests/test_compositor_regions_to_spans.py index 930489a89..7ddaecedb 100644 --- a/tests/test_region_group.py +++ b/tests/test_compositor_regions_to_spans.py @@ -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)] diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 72d5de462..9d5676f75 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -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)