Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Widget.render_delta_lines #4353

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <region>, <clip region>, and <strips>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand All @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions src/textual/widgets/_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading