diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6b786e8b26..e8ad2e945c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -899,12 +899,18 @@ def cuts(self) -> list[list[int]]: return self._cuts def _get_renders( - self, crop: Region | None = None + self, + crop: Region | None = None, + allow_delta_updates: bool = False, ) -> Iterable[tuple[Region, Region, list[Strip]]]: """Get rendered widgets (lists of segments) in the composition. Args: crop: Region to crop to, or `None` for entire screen. + allow_delta_updates: Whether we can issue partial updates for widgets or + not. If `True`, widgets with `_enable_delta_updates` will produce only + updates for lines inside `crop` that have changed. If `False`, each + widget produces the full render for the region inside `crop`. Returns: An iterable of , , and @@ -935,17 +941,16 @@ def _get_renders( for widget, region, clip in widget_regions: if contains_region(clip, region): - yield region, clip, widget.render_lines( - _Region(0, 0, region.width, region.height) - ) + region_to_render = _Region(0, 0, region.width, region.height) else: new_x, new_y, new_width, new_height = intersection(region, clip) - if new_width and new_height: - yield region, clip, widget.render_lines( - _Region( - new_x - region.x, new_y - region.y, new_width, new_height - ) - ) + region_to_render = _Region( + new_x - region.x, new_y - region.y, new_width, new_height + ) + if allow_delta_updates and widget._enable_delta_updates: + yield from widget.render_delta_lines(region, clip, region_to_render) + else: + yield region, clip, widget.render_lines(region_to_render) def render_update( self, full: bool = False, screen_stack: list[Screen] | None = None @@ -995,7 +1000,7 @@ def render_partial_update(self) -> ChopsUpdate | None: is_rendered_line = {y for y, _, _ in spans}.__contains__ else: return None - chops = self._render_chops(crop, is_rendered_line) + chops = self._render_chops(crop, is_rendered_line, allow_delta_updates=True) chop_ends = [cut_set[1:] for cut_set in self.cuts] return ChopsUpdate(chops, spans, chop_ends) @@ -1013,12 +1018,17 @@ def _render_chops( self, crop: Region, is_rendered_line: Callable[[int], bool], + allow_delta_updates: bool = False, ) -> Sequence[Mapping[int, Strip | None]]: """Render update 'chops'. Args: crop: Region to crop to. is_rendered_line: Callable to check if line should be rendered. + allow_delta_updates: Whether we can issue partial updates for widgets or + not. If `True`, widgets with `_enable_delta_updates` will produce only + updates for lines inside `crop` that have changed. If `False`, each + widget produces the full render for the region inside `crop`. Returns: Chops structure. @@ -1031,7 +1041,7 @@ def _render_chops( cut_strips: Iterable[Strip] # Go through all the renders in reverse order and fill buckets with no render - renders = self._get_renders(crop) + renders = self._get_renders(crop, allow_delta_updates=allow_delta_updates) intersection = Region.intersection for region, clip, strips in renders: diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 2700419192..58cc3084f8 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -234,6 +234,7 @@ def __init__( self.grabbed_position: float = 0 super().__init__(name=name) self.auto_links = False + self._enable_delta_updates = False window_virtual_size: Reactive[int] = Reactive(100) window_size: Reactive[int] = Reactive(0) @@ -366,6 +367,7 @@ class ScrollBarCorner(Widget): def __init__(self, name: str | None = None): super().__init__(name=name) + self._enable_delta_updates = False def render(self) -> RenderableType: assert self.parent is not None diff --git a/src/textual/widget.py b/src/textual/widget.py index 4bfaabdee0..fe17348d98 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -8,7 +8,7 @@ from collections import Counter from contextlib import asynccontextmanager from fractions import Fraction -from itertools import islice +from itertools import groupby, islice from types import TracebackType from typing import ( TYPE_CHECKING, @@ -361,6 +361,13 @@ def __init__( self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} + self._enable_delta_updates: bool = True + """Get the compositor to only rerender lines that changed between consecutive + updates. + """ + self._delta_updates_cache: tuple[Region, Region, Region, list[Strip]] | None = ( + None + ) self._tooltip: RenderableType | None = None """The tooltip content.""" @@ -3265,9 +3272,58 @@ def render_lines(self, crop: Region) -> list[Strip]: Returns: A list of list of segments. """ + self._delta_updates_cache = None strips = self._styles_cache.render_widget(self, crop) return strips + def render_delta_lines( + self, + region: Region, + clip: Region, + region_to_render: Region, + ) -> Generator[tuple[Region, Region, list[Strip]], None, None]: + """Render the lines of the widget that changed since the last render. + + When a widget is rendered consecutively in the same region + """ + print(self) + strips = self._styles_cache.render_widget(self, region_to_render) + + if self._delta_updates_cache is None: + self._delta_updates_cache = (region, clip, region_to_render, strips) + yield region, clip, strips + return + + cached_region, cached_clip, cached_region_to_render, cached_strips = ( + self._delta_updates_cache + ) + self._delta_updates_cache = (region, clip, region_to_render, strips) + if ( + cached_region != region + or cached_clip != clip + or cached_region_to_render != region_to_render + ): + yield region, clip, strips + return + + cached_strips_iter = iter(cached_strips) + y_offset = 0 + for matches_cache, new_strips in groupby( + strips, key=lambda strip: strip == next(cached_strips_iter) + ): + new_strips = list(new_strips) + if matches_cache: + y_offset += len(new_strips) + continue + reg = Region( + region_to_render.x + region.x, + region_to_render.y + region.y + y_offset, + region.width, + len(new_strips), + ) + y_offset += len(new_strips) + yield reg, clip, new_strips + def get_style_at(self, x: int, y: int) -> Style: """Get the Rich style in a widget at a given relative offset. diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 686382433d..c0086a2088 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1008,6 +1008,15 @@ def get_line_width(line: _TreeLine[TreeDataType]) -> int: self.cursor_line = -1 self.refresh() + def render_delta_lines( + self, + region: Region, + clip: Region, + region_to_render: Region, + ) -> tuple[Region, Region, list[Strip]]: + self._pseudo_class_state = self.get_pseudo_class_state() + return super().render_delta_lines(region, clip, region_to_render) + def render_lines(self, crop: Region) -> list[Strip]: self._pseudo_class_state = self.get_pseudo_class_state() return super().render_lines(crop)