From 8c9263be548cd071994b47611061e780ac3e3337 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:04:06 +0100 Subject: [PATCH 01/18] compare animation frames --- src/textual/_animator.py | 1 + src/textual/_compositor.py | 87 ++++++++++++++++++++++++++------------ src/textual/app.py | 5 +++ src/textual/geometry.py | 4 +- src/textual/screen.py | 16 ++++--- 5 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 6af8c912c..c6d228bbc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -249,5 +249,6 @@ class Animator: self.on_animation_frame() def on_animation_frame(self) -> None: + return # TODO: We should be able to do animation without refreshing everything self.target.screen.refresh_layout() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 80d1d14ed..052eda535 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,7 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size - +from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines @@ -59,6 +59,11 @@ class MapGeometry(NamedTuple): virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container container_size: Size # The container size (area not occupied by scrollbars) + @property + def visible_region(self) -> Region: + """The Widget region after clipping.""" + return self.clip.intersection(self.region) + CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" @@ -77,12 +82,13 @@ class LayoutUpdate: x = self.region.x new_line = Segment.line() move_to = Control.move_to + yield Control.home() for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): - yield Control.home() yield move_to(x, y) yield from line if not last: yield new_line + yield Control.home() def __rich_repr__(self) -> rich.repr.Result: x, y, width, height = self.region @@ -109,11 +115,13 @@ class SpansUpdate: ) -> RenderResult: move_to = Control.move_to new_line = Segment.line() + yield Control.home() for last, (y, x, segments) in loop_last(self.spans): yield move_to(x, y) yield from segments if not last: yield new_line + yield Control.home() def __rich_repr__(self) -> rich.repr.Result: yield [(y, x, "...") for y, x, _segments in self.spans] @@ -144,6 +152,11 @@ 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 + self._dirty_regions: set[Region] = set() + + def add_dirty_regions(self, regions: Iterable[Region]) -> None: + self._dirty_regions.update(regions) + @classmethod def _regions_to_spans( cls, regions: Iterable[Region] @@ -198,11 +211,12 @@ class Compositor: self.root = parent self.size = size + old_map = self.map.copy() # TODO: Handle virtual size map, widgets = self._arrange_root(parent) - old_widgets = set(self.map.keys()) - new_widgets = set(map.keys()) + old_widgets = self.map.keys() + new_widgets = map.keys() # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets @@ -225,10 +239,23 @@ class Compositor: if widget in old_widgets and widget.size != region.size } + screen_region = size.region + with timer("delta"): + updates: set[Region] = { + map[widget].visible_region + for widget in (shown_widgets | hidden_widgets) + } + add_region = updates.add + for widget in old_widgets & new_widgets: + if map[widget] != old_map[widget]: + add_region(map[widget].visible_region) + add_region(old_map[widget].visible_region) + self._dirty_regions.update( + [screen_region.intersection(update) for update in updates] + ) + return ReflowResult( - hidden=hidden_widgets, - shown=shown_widgets, - resized=resized_widgets, + hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets ) def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: @@ -516,27 +543,39 @@ class Compositor: ] return segment_lines - def render(self, regions: list[Region] | None = None) -> RenderableType: + def render(self, full: bool = False) -> RenderableType: """Render a layout. Args: - clip (Optional[Region]): Region to clip to. + full (bool): Perform a full render (ignore dirty regions) Returns: SegmentLines: A renderable """ width, height = self.size screen_region = Region(0, 0, width, height) - if regions: - # Create a crop regions that surrounds all updates - 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 - spans = [] - is_rendered_line = lambda y: True + if full: + update_regions = set() + crop = screen_region + is_rendered_line = lambda y: True + else: + update_regions = self._dirty_regions.copy() + self._dirty_regions.clear() + + if update_regions: + # Create a crop regions that surrounds all updates + crop = Region.from_union(list(update_regions)).intersection( + screen_region + ) + spans = list(self._regions_to_spans(update_regions)) + is_rendered_line = {y for y, _, _ in spans}.__contains__ + else: + crop = screen_region + spans = [] + is_rendered_line = lambda y: True + + print("CROP", crop) _Segment = Segment divide = _Segment.divide @@ -569,7 +608,6 @@ class Compositor: else: render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts] - # print(relative_cuts) _, *cut_segments = divide(line, relative_cuts) # Since we are painting front to back, the first segments for a cut "wins" @@ -578,7 +616,7 @@ class Compositor: if chops_line[cut] is None: chops_line[cut] = segments - if regions: + if update_regions: crop_y, crop_y2 = crop.y_extents render_lines = self._assemble_chops(chops[crop_y:crop_y2]) render_spans = [ @@ -594,17 +632,15 @@ class Compositor: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - yield self.render() + yield self.render(full=True) - def update_widgets(self, widgets: set[Widget]) -> RenderableType | None: + def update_widgets(self, widgets: set[Widget]): """Update a given widget in the composition. Args: console (Console): Console instance. widget (Widget): Widget to update. - Returns: - LayoutUpdate | None: A renderable or None if nothing to render. """ regions: list[Region] = [] add_region = regions.append @@ -613,5 +649,4 @@ class Compositor: update_region = region.intersection(clip) if update_region: add_region(update_region) - update = self.render(regions or None) - return update + self.add_dirty_regions(regions) diff --git a/src/textual/app.py b/src/textual/app.py index c59999aed..6da4d454e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -875,10 +875,15 @@ class App(Generic[ReturnType], DOMNode): return if not self._closed: console = self.console + if self._sync_available: + console.file.write("\x1bP=1s\x1b\\") try: console.print(renderable) except Exception as error: self.on_exception(error) + if self._sync_available: + console.file.write("\x1bP=2s\x1b\\") + console.file.flush() def measure(self, renderable: RenderableType, max_width=100_000) -> int: """Get the optimal width for a widget or renderable. diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3973e3eac..9e4480306 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, Iterable, NamedTuple, Tuple, Union, TypeVar +from typing import Any, cast, NamedTuple, Sequence, Tuple, Union, TypeVar SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]] @@ -182,7 +182,7 @@ class Region(NamedTuple): height: int = 0 @classmethod - def from_union(cls, regions: list[Region]) -> Region: + def from_union(cls, regions: Sequence[Region]) -> Region: """Create a Region from the union of other regions. Args: diff --git a/src/textual/screen.py b/src/textual/screen.py index 51de850ff..11f9694a4 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -94,13 +94,11 @@ class Screen(Widget): def _on_update(self) -> None: """Called by the _update_timer.""" - # Render widgets together - if self._dirty_widgets: + if self._dirty_widgets or self._dirty_regions: self.log(dirty=self._dirty_widgets) - display_update = self._compositor.update_widgets(self._dirty_widgets) - if display_update is not None: - self.app.display(display_update) + self._compositor.update_widgets(self._dirty_widgets) + self.app.display(self._compositor.render()) self._dirty_widgets.clear() self._update_timer.pause() @@ -109,8 +107,9 @@ class Screen(Widget): if not self.size: return # This paint the entire screen, so replaces the batched dirty widgets + # self._on_update() + self._compositor.update_widgets(self._dirty_widgets) self._update_timer.pause() - self._dirty_widgets.clear() try: hidden, shown, resized = self._compositor.reflow(self, self.size) @@ -140,7 +139,10 @@ class Screen(Widget): except Exception as error: self.app.on_exception(error) return - self.app.refresh() + + display_update = self._compositor.render() + if display_update is not None: + self.app.display(display_update) async def handle_update(self, message: messages.Update) -> None: message.stop() From 37fb5884a802a3c66e888930e784d1185629b855 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:07:03 +0100 Subject: [PATCH 02/18] docstring --- src/textual/_compositor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 052eda535..4b3a763e5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -155,6 +155,11 @@ class Compositor: self._dirty_regions: set[Region] = set() def add_dirty_regions(self, regions: Iterable[Region]) -> None: + """Add dirty regions to be repainted next call to render. + + Args: + regions (Iterable[Region]): Regions that are "dirty" (changed since last render). + """ self._dirty_regions.update(regions) @classmethod From 46b94a38f88e8ff5ea315ea8d72870428c11b742 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:18:58 +0100 Subject: [PATCH 03/18] comments --- src/textual/_compositor.py | 29 ++++++++++++++--------------- src/textual/screen.py | 1 - 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4b3a763e5..61ee2fbba 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,20 +244,20 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - screen_region = size.region - with timer("delta"): - updates: set[Region] = { - map[widget].visible_region - for widget in (shown_widgets | hidden_widgets) - } - add_region = updates.add - for widget in old_widgets & new_widgets: - if map[widget] != old_map[widget]: - add_region(map[widget].visible_region) - add_region(old_map[widget].visible_region) - self._dirty_regions.update( - [screen_region.intersection(update) for update in updates] - ) + # Updates (regions) that need repainting + # Hidden widgets and shown widgets will need repainting + updates: set[Region] = { + map[widget].visible_region for widget in (shown_widgets | hidden_widgets) + } + add_region = updates.add + # Widgets that have moved in any way (position, ordering, etc) + for widget in old_widgets & new_widgets: + if map[widget] != old_map[widget]: + add_region(map[widget].visible_region) + add_region(old_map[widget].visible_region) + # Crop region to the screen + crop_screen = size.region.intersection + self._dirty_regions.update([crop_screen(update) for update in updates]) return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets @@ -580,7 +580,6 @@ class Compositor: spans = [] is_rendered_line = lambda y: True - print("CROP", crop) _Segment = Segment divide = _Segment.divide diff --git a/src/textual/screen.py b/src/textual/screen.py index 11f9694a4..fc44f4718 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -96,7 +96,6 @@ class Screen(Widget): """Called by the _update_timer.""" # Render widgets together if self._dirty_widgets or self._dirty_regions: - self.log(dirty=self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets) self.app.display(self._compositor.render()) self._dirty_widgets.clear() From c15db0613d7e1aa400ba687cf9a02d535e78d4d3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:28:20 +0100 Subject: [PATCH 04/18] optimization --- src/textual/_compositor.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 61ee2fbba..ed80083c6 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -246,18 +246,31 @@ class Compositor: # Updates (regions) that need repainting # Hidden widgets and shown widgets will need repainting - updates: set[Region] = { - map[widget].visible_region for widget in (shown_widgets | hidden_widgets) - } - add_region = updates.add - # Widgets that have moved in any way (position, ordering, etc) - for widget in old_widgets & new_widgets: - if map[widget] != old_map[widget]: - add_region(map[widget].visible_region) - add_region(old_map[widget].visible_region) - # Crop region to the screen crop_screen = size.region.intersection - self._dirty_regions.update([crop_screen(update) for update in updates]) + + updates = self._dirty_regions + updates.update( + [ + crop_screen(map[widget].visible_region) + for widget in (shown_widgets | hidden_widgets) + ] + ) + # Widgets that have moved in any way (position, ordering, etc) + changed_widgets = [ + widget + for widget in old_widgets & new_widgets + if map[widget] != old_map[widget] + ] + if changed_widgets: + updates.update( + [crop_screen(map[widget].visible_region) for widget in changed_widgets] + ) + updates.update( + [ + crop_screen(old_map[widget].visible_region) + for widget in changed_widgets + ] + ) return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets From 4208c237786d3f477236c96f74af88afd31f8535 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 18:31:24 +0100 Subject: [PATCH 05/18] comments --- src/textual/_compositor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index ed80083c6..152926c09 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,10 +244,9 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - # Updates (regions) that need repainting + # Calculate regions that need repainting # Hidden widgets and shown widgets will need repainting crop_screen = size.region.intersection - updates = self._dirty_regions updates.update( [ @@ -255,13 +254,14 @@ class Compositor: for widget in (shown_widgets | hidden_widgets) ] ) - # Widgets that have moved in any way (position, ordering, etc) + # Widgets that have moved in any way (position, ordering, etc.) changed_widgets = [ widget for widget in old_widgets & new_widgets if map[widget] != old_map[widget] ] if changed_widgets: + # Paint the old position and the new position updates.update( [crop_screen(map[widget].visible_region) for widget in changed_widgets] ) From 1882bd608caa3a01cad3051937e97187b3b97396 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 21:57:51 +0100 Subject: [PATCH 06/18] remove import --- src/textual/_compositor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 152926c09..1e4857b49 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size -from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines From e17e82698f9dcafe36f8592640a75a56e598bd01 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:17:34 +0100 Subject: [PATCH 07/18] simplify --- src/textual/_compositor.py | 8 ++------ src/textual/screen.py | 2 +- src/textual/widget.py | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1e4857b49..3cbc15441 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -81,13 +81,11 @@ class LayoutUpdate: x = self.region.x new_line = Segment.line() move_to = Control.move_to - yield Control.home() for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): yield move_to(x, y) yield from line if not last: yield new_line - yield Control.home() def __rich_repr__(self) -> rich.repr.Result: x, y, width, height = self.region @@ -114,13 +112,11 @@ class SpansUpdate: ) -> RenderResult: move_to = Control.move_to new_line = Segment.line() - yield Control.home() for last, (y, x, segments) in loop_last(self.spans): yield move_to(x, y) yield from segments if not last: yield new_line - yield Control.home() def __rich_repr__(self) -> rich.repr.Result: yield [(y, x, "...") for y, x, _segments in self.spans] @@ -151,6 +147,7 @@ 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 + # Regions that require an update self._dirty_regions: set[Region] = set() def add_dirty_regions(self, regions: Iterable[Region]) -> None: @@ -592,8 +589,7 @@ class Compositor: spans = [] is_rendered_line = lambda y: True - _Segment = Segment - divide = _Segment.divide + divide = Segment.divide # Maps each cut on to a list of segments cuts = self.cuts diff --git a/src/textual/screen.py b/src/textual/screen.py index fc44f4718..fc1a7145b 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -95,7 +95,7 @@ class Screen(Widget): def _on_update(self) -> None: """Called by the _update_timer.""" # Render widgets together - if self._dirty_widgets or self._dirty_regions: + if self._dirty_widgets: self._compositor.update_widgets(self._dirty_widgets) self.app.display(self._compositor.render()) self._dirty_widgets.clear() diff --git a/src/textual/widget.py b/src/textual/widget.py index 09b315eb0..ff5a90933 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -104,8 +104,8 @@ class Widget(DOMNode): has_focus = Reactive(False) descendant_has_focus = Reactive(False) mouse_over = Reactive(False) - scroll_x = Reactive(0.0, repaint=False) - scroll_y = Reactive(0.0, repaint=False) + scroll_x = Reactive(0.0, repaint=False, layout=True) + scroll_y = Reactive(0.0, repaint=False, layout=True) scroll_target_x = Reactive(0.0, repaint=False) scroll_target_y = Reactive(0.0, repaint=False) show_vertical_scrollbar = Reactive(False, layout=True) From 220b8bcccc6ee0884b39ee034bce708d57326806 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:26:10 +0100 Subject: [PATCH 08/18] comment --- src/textual/_animator.py | 6 ------ src/textual/_compositor.py | 1 + 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index c6d228bbc..c03378953 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -246,9 +246,3 @@ class Animator: animation = self._animations[animation_key] if animation(animation_time): del self._animations[animation_key] - self.on_animation_frame() - - def on_animation_frame(self) -> None: - return - # TODO: We should be able to do animation without refreshing everything - self.target.screen.refresh_layout() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3cbc15441..141451591 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -212,6 +212,7 @@ class Compositor: 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.map.copy() # TODO: Handle virtual size map, widgets = self._arrange_root(parent) From da3f8f98189291dca5c130719ce9dd430b6af341 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:31:56 +0100 Subject: [PATCH 09/18] simplify --- src/textual/_compositor.py | 2 +- src/textual/app.py | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 141451591..d7b17ff2d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -645,7 +645,7 @@ class Compositor: def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: - yield self.render(full=True) + yield self.render() def update_widgets(self, widgets: set[Widget]): """Update a given widget in the composition. diff --git a/src/textual/app.py b/src/textual/app.py index 6da4d454e..b875e7aa1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -30,10 +30,8 @@ else: import rich import rich.repr from rich.console import Console, RenderableType -from rich.control import Control from rich.measure import Measurement from rich.protocol import is_renderable -from rich.screen import Screen as ScreenRenderable from rich.segment import Segments from rich.style import Style from rich.traceback import Traceback @@ -845,13 +843,7 @@ class App(Generic[ReturnType], DOMNode): try: if self._sync_available: console.file.write("\x1bP=1s\x1b\\") - console.print( - ScreenRenderable( - Control.home(), - self.screen._compositor, - Control.home(), - ) - ) + console.print(self.screen._compositor) if self._sync_available: console.file.write("\x1bP=2s\x1b\\") console.file.flush() From 1b9a2704117230ee869af662266341ae62ab73d1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:35:01 +0100 Subject: [PATCH 10/18] simplify --- src/textual/_compositor.py | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d7b17ff2d..1cb3f1f73 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -558,37 +558,27 @@ class Compositor: ] return segment_lines - def render(self, full: bool = False) -> RenderableType: + def render(self) -> RenderableType: """Render a layout. - Args: - full (bool): Perform a full render (ignore dirty regions) - Returns: SegmentLines: A renderable """ width, height = self.size screen_region = Region(0, 0, width, height) - if full: - update_regions = set() - crop = screen_region - is_rendered_line = lambda y: True - else: - update_regions = self._dirty_regions.copy() - self._dirty_regions.clear() + update_regions = self._dirty_regions.copy() + self._dirty_regions.clear() - if update_regions: - # Create a crop regions that surrounds all updates - crop = Region.from_union(list(update_regions)).intersection( - screen_region - ) - spans = list(self._regions_to_spans(update_regions)) - is_rendered_line = {y for y, _, _ in spans}.__contains__ - else: - crop = screen_region - spans = [] - is_rendered_line = lambda y: True + if update_regions: + # Create a crop regions that surrounds all updates + crop = Region.from_union(list(update_regions)).intersection(screen_region) + spans = list(self._regions_to_spans(update_regions)) + is_rendered_line = {y for y, _, _ in spans}.__contains__ + else: + crop = screen_region + spans = [] + is_rendered_line = lambda y: True divide = Segment.divide From 5d50fa2a9052c339d23ec55196311fda702b3d29 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:42:03 +0100 Subject: [PATCH 11/18] tidy and typing --- src/textual/_compositor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1cb3f1f73..38fd2c3ab 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -270,7 +270,9 @@ class Compositor: ) return ReflowResult( - hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets + hidden=hidden_widgets, + shown=shown_widgets, + resized=resized_widgets, ) def _arrange_root(self, root: Widget) -> tuple[CompositorMap, set[Widget]]: @@ -637,7 +639,7 @@ class Compositor: ) -> RenderResult: yield self.render() - def update_widgets(self, widgets: set[Widget]): + def update_widgets(self, widgets: set[Widget]) -> None: """Update a given widget in the composition. Args: From 2d4d30c312be0a7e04e005a90e6c292448275709 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:42:33 +0100 Subject: [PATCH 12/18] comment --- src/textual/screen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index fc1a7145b..9375cd17f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -106,7 +106,6 @@ class Screen(Widget): if not self.size: return # This paint the entire screen, so replaces the batched dirty widgets - # self._on_update() self._compositor.update_widgets(self._dirty_widgets) self._update_timer.pause() try: From 97942faf3602e0ad8bcc53764cecea471c59aa2e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 15 May 2022 22:49:34 +0100 Subject: [PATCH 13/18] remove refresh --- src/textual/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b875e7aa1..38d49e3b4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -552,7 +552,6 @@ class App(Generic[ReturnType], DOMNode): def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.register(self.screen, *anon_widgets, **widgets) - self.screen.refresh() def push_screen(self, screen: Screen | None = None) -> Screen: """Push a new screen on the screen stack. @@ -757,7 +756,6 @@ class App(Generic[ReturnType], DOMNode): widgets = list(self.compose()) if widgets: self.mount(*widgets) - self.screen.refresh() async def on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" From 77fcef7a540de66b3d632a5d151e507c8822eca0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 08:44:43 +0100 Subject: [PATCH 14/18] optimized deltas --- src/textual/_compositor.py | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 38fd2c3ab..50f2e7c3d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,6 +26,7 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size +from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines @@ -228,7 +229,7 @@ class Compositor: self.map = map self.widgets = widgets - # Copy renders if the size hasn't changed + # Get a map of regions self.regions = { widget: (region, clip) for widget, (region, _order, clip, _, _) in map.items() @@ -241,33 +242,13 @@ class Compositor: if widget in old_widgets and widget.size != region.size } - # Calculate regions that need repainting - # Hidden widgets and shown widgets will need repainting + # Gets pairs of tuples of (Widget, MapGeometry) which have changed + # i.e. if something is moved / deleted / added crop_screen = size.region.intersection - updates = self._dirty_regions - updates.update( - [ - crop_screen(map[widget].visible_region) - for widget in (shown_widgets | hidden_widgets) - ] + changes: set[tuple[Widget, MapGeometry]] = self.map.items() ^ old_map.items() + self._dirty_regions.update( + [crop_screen(map_geometry.visible_region) for _, map_geometry in changes] ) - # Widgets that have moved in any way (position, ordering, etc.) - changed_widgets = [ - widget - for widget in old_widgets & new_widgets - if map[widget] != old_map[widget] - ] - if changed_widgets: - # Paint the old position and the new position - updates.update( - [crop_screen(map[widget].visible_region) for widget in changed_widgets] - ) - updates.update( - [ - crop_screen(old_map[widget].visible_region) - for widget in changed_widgets - ] - ) return ReflowResult( hidden=hidden_widgets, @@ -584,6 +565,8 @@ class Compositor: divide = Segment.divide + print("CROP", crop) + # Maps each cut on to a list of segments cuts = self.cuts # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None. From e0db9914345aa46a61847fe06c182b427a56eb62 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 09:15:14 +0100 Subject: [PATCH 15/18] optimize, fix scroll_to_region --- src/textual/_compositor.py | 22 +++++++++++++++------- src/textual/widget.py | 15 +++++---------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 50f2e7c3d..42f5ad3fa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,11 +244,18 @@ class Compositor: # Gets pairs of tuples of (Widget, MapGeometry) which have changed # i.e. if something is moved / deleted / added - crop_screen = size.region.intersection - changes: set[tuple[Widget, MapGeometry]] = self.map.items() ^ old_map.items() - self._dirty_regions.update( - [crop_screen(map_geometry.visible_region) for _, map_geometry in changes] - ) + screen = size.region + if screen not in self._dirty_regions: + crop_screen = screen.intersection + changes: set[tuple[Widget, MapGeometry]] = ( + self.map.items() ^ old_map.items() + ) + self._dirty_regions.update( + [ + crop_screen(map_geometry.visible_region) + for _, map_geometry in changes + ] + ) return ReflowResult( hidden=hidden_widgets, @@ -551,6 +558,9 @@ class Compositor: screen_region = Region(0, 0, width, height) update_regions = self._dirty_regions.copy() + if screen_region in update_regions: + # If one of the updates is the entire screen, then we only need one update + update_regions.clear() self._dirty_regions.clear() if update_regions: @@ -565,8 +575,6 @@ class Compositor: divide = Segment.divide - print("CROP", crop) - # Maps each cut on to a list of segments cuts = self.cuts # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None. diff --git a/src/textual/widget.py b/src/textual/widget.py index ff5a90933..16de19f07 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -431,16 +431,13 @@ class Widget(DOMNode): Returns: bool: True if the scroll position changed, otherwise False. """ - screen = self.screen + try: - widget_geometry = screen.find_widget(widget) - container_geometry = screen.find_widget(self) + widget_region = widget.content_region + container_region = self.content_region except errors.NoWidget: return False - widget_region = widget.content_region + widget_geometry.region.origin - container_region = self.content_region + container_geometry.region.origin - if widget_region in container_region: # Widget is visible, nothing to do return False @@ -610,10 +607,8 @@ class Widget(DOMNode): @property def content_region(self) -> Region: - """A region relative to the Widget origin that contains the content.""" - x, y = self.styles.content_gutter.top_left - width, height = self._container_size - return Region(x, y, width, height) + """Gets an absolute region containing the content (minus padding and border).""" + return self.region.shrink(self.styles.content_gutter) @property def content_offset(self) -> Offset: From 36dd8b67e822f8fc032e743bc0c1fcd74a631833 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 09:19:37 +0100 Subject: [PATCH 16/18] import --- src/textual/_compositor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 42f5ad3fa..8f74c13a4 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from rich.style import Style from . import errors from .geometry import Region, Offset, Size -from ._profile import timer from ._loop import loop_last from ._segment_tools import line_crop from ._types import Lines From 3e45949a5f1985d4c5ab9de9ed0a3e3e02a1a734 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 11:47:40 +0100 Subject: [PATCH 17/18] tody --- sandbox/basic.css | 1 - src/textual/_compositor.py | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/sandbox/basic.css b/sandbox/basic.css index 3934f521c..e474a0d5e 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -6,7 +6,6 @@ transition: color 300ms linear, background 300ms linear; } - * { scrollbar-background: $panel-darken-2; scrollbar-background-hover: $panel-darken-3; diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 8f74c13a4..a49843808 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -37,7 +37,6 @@ else: # pragma: no cover if TYPE_CHECKING: - from .screen import Screen from .widget import Widget @@ -214,11 +213,10 @@ class Compositor: # Keep a copy of the old map because we're going to compare it with the update old_map = self.map.copy() - # TODO: Handle virtual size + old_widgets = old_map.keys() map, widgets = self._arrange_root(parent) - - old_widgets = self.map.keys() new_widgets = map.keys() + # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets From ec8eda7c6de0b6c043882bc4faa9f2c1c95a59dc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 16 May 2022 15:17:10 +0100 Subject: [PATCH 18/18] fix test --- tests/test_animator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_animator.py b/tests/test_animator.py index 6f9e500df..76caf31dd 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -208,7 +208,6 @@ def test_animator(): animator() assert animate_test.foo == 0 - assert animator._on_animation_frame_called animator._time = 5 animator()