Merge pull request #512 from Textualize/compositor-deltas

Compositor deltas
This commit is contained in:
Will McGugan
2022-05-17 10:28:14 +01:00
committed by GitHub
8 changed files with 73 additions and 62 deletions

View File

@@ -6,7 +6,6 @@
transition: color 300ms linear, background 300ms linear; transition: color 300ms linear, background 300ms linear;
} }
* { * {
scrollbar-background: $panel-darken-2; scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3; scrollbar-background-hover: $panel-darken-3;

View File

@@ -246,8 +246,3 @@ class Animator:
animation = self._animations[animation_key] animation = self._animations[animation_key]
if animation(animation_time): if animation(animation_time):
del self._animations[animation_key] del self._animations[animation_key]
self.on_animation_frame()
def on_animation_frame(self) -> None:
# TODO: We should be able to do animation without refreshing everything
self.target.screen.refresh_layout()

View File

@@ -26,7 +26,6 @@ from rich.style import Style
from . import errors from . import errors
from .geometry import Region, Offset, Size from .geometry import Region, Offset, Size
from ._loop import loop_last from ._loop import loop_last
from ._segment_tools import line_crop from ._segment_tools import line_crop
from ._types import Lines from ._types import Lines
@@ -38,7 +37,6 @@ else: # pragma: no cover
if TYPE_CHECKING: if TYPE_CHECKING:
from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -59,6 +57,11 @@ class MapGeometry(NamedTuple):
virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container 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) 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]" CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
@@ -78,7 +81,6 @@ class LayoutUpdate:
new_line = Segment.line() new_line = Segment.line()
move_to = Control.move_to move_to = Control.move_to
for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)):
yield Control.home()
yield move_to(x, y) yield move_to(x, y)
yield from line yield from line
if not last: if not last:
@@ -144,6 +146,17 @@ 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
# Regions that require an update
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 @classmethod
def _regions_to_spans( def _regions_to_spans(
cls, regions: Iterable[Region] cls, regions: Iterable[Region]
@@ -198,11 +211,12 @@ class Compositor:
self.root = parent self.root = parent
self.size = size self.size = size
# TODO: Handle virtual size # Keep a copy of the old map because we're going to compare it with the update
old_map = self.map.copy()
old_widgets = old_map.keys()
map, widgets = self._arrange_root(parent) map, widgets = self._arrange_root(parent)
new_widgets = map.keys()
old_widgets = set(self.map.keys())
new_widgets = set(map.keys())
# Newly visible widgets # Newly visible widgets
shown_widgets = new_widgets - old_widgets shown_widgets = new_widgets - old_widgets
# Newly hidden widgets # Newly hidden widgets
@@ -212,7 +226,7 @@ class Compositor:
self.map = map self.map = map
self.widgets = widgets self.widgets = widgets
# Copy renders if the size hasn't changed # Get a map of regions
self.regions = { self.regions = {
widget: (region, clip) widget: (region, clip)
for widget, (region, _order, clip, _, _) in map.items() for widget, (region, _order, clip, _, _) in map.items()
@@ -225,6 +239,21 @@ class Compositor:
if widget in old_widgets and widget.size != region.size if widget in old_widgets and widget.size != region.size
} }
# Gets pairs of tuples of (Widget, MapGeometry) which have changed
# i.e. if something is moved / deleted / added
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( return ReflowResult(
hidden=hidden_widgets, hidden=hidden_widgets,
shown=shown_widgets, shown=shown_widgets,
@@ -516,29 +545,32 @@ class Compositor:
] ]
return segment_lines return segment_lines
def render(self, regions: list[Region] | None = None) -> RenderableType: def render(self) -> RenderableType:
"""Render a layout. """Render a layout.
Args:
clip (Optional[Region]): Region to clip to.
Returns: Returns:
SegmentLines: A renderable SegmentLines: A renderable
""" """
width, height = self.size width, height = self.size
screen_region = Region(0, 0, width, height) screen_region = Region(0, 0, width, height)
if regions:
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:
# Create a crop regions that surrounds all updates # Create a crop regions that surrounds all updates
crop = Region.from_union(regions).intersection(screen_region) crop = Region.from_union(list(update_regions)).intersection(screen_region)
spans = list(self._regions_to_spans(regions)) spans = list(self._regions_to_spans(update_regions))
is_rendered_line = {y for y, _, _ in spans}.__contains__ is_rendered_line = {y for y, _, _ in spans}.__contains__
else: else:
crop = screen_region crop = screen_region
spans = [] spans = []
is_rendered_line = lambda y: True is_rendered_line = lambda y: True
_Segment = Segment divide = Segment.divide
divide = _Segment.divide
# Maps each cut on to a list of segments # Maps each cut on to a list of segments
cuts = self.cuts cuts = self.cuts
@@ -569,7 +601,6 @@ class Compositor:
else: else:
render_x = render_region.x render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts] relative_cuts = [cut - render_x for cut in final_cuts]
# print(relative_cuts)
_, *cut_segments = divide(line, relative_cuts) _, *cut_segments = divide(line, relative_cuts)
# Since we are painting front to back, the first segments for a cut "wins" # Since we are painting front to back, the first segments for a cut "wins"
@@ -578,7 +609,7 @@ class Compositor:
if chops_line[cut] is None: if chops_line[cut] is None:
chops_line[cut] = segments chops_line[cut] = segments
if regions: if update_regions:
crop_y, crop_y2 = crop.y_extents crop_y, crop_y2 = crop.y_extents
render_lines = self._assemble_chops(chops[crop_y:crop_y2]) render_lines = self._assemble_chops(chops[crop_y:crop_y2])
render_spans = [ render_spans = [
@@ -596,15 +627,13 @@ class Compositor:
) -> RenderResult: ) -> RenderResult:
yield self.render() yield self.render()
def update_widgets(self, widgets: set[Widget]) -> RenderableType | None: def update_widgets(self, widgets: set[Widget]) -> None:
"""Update a given widget in the composition. """Update a given widget in the composition.
Args: Args:
console (Console): Console instance. console (Console): Console instance.
widget (Widget): Widget to update. widget (Widget): Widget to update.
Returns:
LayoutUpdate | None: A renderable or None if nothing to render.
""" """
regions: list[Region] = [] regions: list[Region] = []
add_region = regions.append add_region = regions.append
@@ -613,5 +642,4 @@ class Compositor:
update_region = region.intersection(clip) update_region = region.intersection(clip)
if update_region: if update_region:
add_region(update_region) add_region(update_region)
update = self.render(regions or None) self.add_dirty_regions(regions)
return update

View File

@@ -30,10 +30,8 @@ else:
import rich import rich
import rich.repr import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.control import Control
from rich.measure import Measurement from rich.measure import Measurement
from rich.protocol import is_renderable from rich.protocol import is_renderable
from rich.screen import Screen as ScreenRenderable
from rich.segment import Segments from rich.segment import Segments
from rich.style import Style from rich.style import Style
from rich.traceback import Traceback from rich.traceback import Traceback
@@ -554,7 +552,6 @@ class App(Generic[ReturnType], DOMNode):
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.screen, *anon_widgets, **widgets) self.register(self.screen, *anon_widgets, **widgets)
self.screen.refresh()
def push_screen(self, screen: Screen | None = None) -> Screen: def push_screen(self, screen: Screen | None = None) -> Screen:
"""Push a new screen on the screen stack. """Push a new screen on the screen stack.
@@ -759,7 +756,6 @@ class App(Generic[ReturnType], DOMNode):
widgets = list(self.compose()) widgets = list(self.compose())
if widgets: if widgets:
self.mount(*widgets) self.mount(*widgets)
self.screen.refresh()
async def on_idle(self) -> None: async def on_idle(self) -> None:
"""Perform actions when there are no messages in the queue.""" """Perform actions when there are no messages in the queue."""
@@ -845,13 +841,7 @@ class App(Generic[ReturnType], DOMNode):
try: try:
if self._sync_available: if self._sync_available:
console.file.write("\x1bP=1s\x1b\\") console.file.write("\x1bP=1s\x1b\\")
console.print( console.print(self.screen._compositor)
ScreenRenderable(
Control.home(),
self.screen._compositor,
Control.home(),
)
)
if self._sync_available: if self._sync_available:
console.file.write("\x1bP=2s\x1b\\") console.file.write("\x1bP=2s\x1b\\")
console.file.flush() console.file.flush()
@@ -875,10 +865,15 @@ class App(Generic[ReturnType], DOMNode):
return return
if not self._closed: if not self._closed:
console = self.console console = self.console
if self._sync_available:
console.file.write("\x1bP=1s\x1b\\")
try: try:
console.print(renderable) console.print(renderable)
except Exception as error: except Exception as error:
self.on_exception(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: def measure(self, renderable: RenderableType, max_width=100_000) -> int:
"""Get the optimal width for a widget or renderable. """Get the optimal width for a widget or renderable.

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, 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]] SpacingDimensions = Union[int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]]
@@ -182,7 +182,7 @@ class Region(NamedTuple):
height: int = 0 height: int = 0
@classmethod @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. """Create a Region from the union of other regions.
Args: Args:

View File

@@ -94,13 +94,10 @@ class Screen(Widget):
def _on_update(self) -> None: def _on_update(self) -> None:
"""Called by the _update_timer.""" """Called by the _update_timer."""
# Render widgets together # Render widgets together
if self._dirty_widgets: if self._dirty_widgets:
self.log(dirty=self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets)
display_update = self._compositor.update_widgets(self._dirty_widgets) self.app.display(self._compositor.render())
if display_update is not None:
self.app.display(display_update)
self._dirty_widgets.clear() self._dirty_widgets.clear()
self._update_timer.pause() self._update_timer.pause()
@@ -109,8 +106,8 @@ class Screen(Widget):
if not self.size: if not self.size:
return return
# This paint the entire screen, so replaces the batched dirty widgets # This paint the entire screen, so replaces the batched dirty widgets
self._compositor.update_widgets(self._dirty_widgets)
self._update_timer.pause() self._update_timer.pause()
self._dirty_widgets.clear()
try: try:
hidden, shown, resized = self._compositor.reflow(self, self.size) hidden, shown, resized = self._compositor.reflow(self, self.size)
@@ -140,7 +137,10 @@ class Screen(Widget):
except Exception as error: except Exception as error:
self.app.on_exception(error) self.app.on_exception(error)
return 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: async def handle_update(self, message: messages.Update) -> None:
message.stop() message.stop()

View File

@@ -104,8 +104,8 @@ class Widget(DOMNode):
has_focus = Reactive(False) has_focus = Reactive(False)
descendant_has_focus = Reactive(False) descendant_has_focus = Reactive(False)
mouse_over = Reactive(False) mouse_over = Reactive(False)
scroll_x = Reactive(0.0, repaint=False) scroll_x = Reactive(0.0, repaint=False, layout=True)
scroll_y = Reactive(0.0, repaint=False) scroll_y = Reactive(0.0, repaint=False, layout=True)
scroll_target_x = Reactive(0.0, repaint=False) scroll_target_x = Reactive(0.0, repaint=False)
scroll_target_y = Reactive(0.0, repaint=False) scroll_target_y = Reactive(0.0, repaint=False)
show_vertical_scrollbar = Reactive(False, layout=True) show_vertical_scrollbar = Reactive(False, layout=True)
@@ -431,16 +431,13 @@ class Widget(DOMNode):
Returns: Returns:
bool: True if the scroll position changed, otherwise False. bool: True if the scroll position changed, otherwise False.
""" """
screen = self.screen
try: try:
widget_geometry = screen.find_widget(widget) widget_region = widget.content_region
container_geometry = screen.find_widget(self) container_region = self.content_region
except errors.NoWidget: except errors.NoWidget:
return False 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: if widget_region in container_region:
# Widget is visible, nothing to do # Widget is visible, nothing to do
return False return False
@@ -610,10 +607,8 @@ class Widget(DOMNode):
@property @property
def content_region(self) -> Region: def content_region(self) -> Region:
"""A region relative to the Widget origin that contains the content.""" """Gets an absolute region containing the content (minus padding and border)."""
x, y = self.styles.content_gutter.top_left return self.region.shrink(self.styles.content_gutter)
width, height = self._container_size
return Region(x, y, width, height)
@property @property
def content_offset(self) -> Offset: def content_offset(self) -> Offset:

View File

@@ -208,7 +208,6 @@ def test_animator():
animator() animator()
assert animate_test.foo == 0 assert animate_test.foo == 0
assert animator._on_animation_frame_called
animator._time = 5 animator._time = 5
animator() animator()