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;
}
* {
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;

View File

@@ -246,8 +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:
# 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 .geometry import Region, Offset, Size
from ._loop import loop_last
from ._segment_tools import line_crop
from ._types import Lines
@@ -38,7 +37,6 @@ else: # pragma: no cover
if TYPE_CHECKING:
from .screen import Screen
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
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]"
@@ -78,7 +81,6 @@ class LayoutUpdate:
new_line = Segment.line()
move_to = Control.move_to
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:
@@ -144,6 +146,17 @@ 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:
"""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
def _regions_to_spans(
cls, regions: Iterable[Region]
@@ -198,11 +211,12 @@ class Compositor:
self.root = parent
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)
new_widgets = map.keys()
old_widgets = set(self.map.keys())
new_widgets = set(map.keys())
# Newly visible widgets
shown_widgets = new_widgets - old_widgets
# Newly hidden widgets
@@ -212,7 +226,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()
@@ -225,6 +239,21 @@ class Compositor:
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(
hidden=hidden_widgets,
shown=shown_widgets,
@@ -516,29 +545,32 @@ class Compositor:
]
return segment_lines
def render(self, regions: list[Region] | None = None) -> RenderableType:
def render(self) -> RenderableType:
"""Render a layout.
Args:
clip (Optional[Region]): Region to clip to.
Returns:
SegmentLines: A renderable
"""
width, height = self.size
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
crop = Region.from_union(regions).intersection(screen_region)
spans = list(self._regions_to_spans(regions))
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
_Segment = Segment
divide = _Segment.divide
divide = Segment.divide
# Maps each cut on to a list of segments
cuts = self.cuts
@@ -569,7 +601,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 +609,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 = [
@@ -596,15 +627,13 @@ class Compositor:
) -> RenderResult:
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.
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 +642,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)

View File

@@ -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
@@ -554,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.
@@ -759,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."""
@@ -845,13 +841,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()
@@ -875,10 +865,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.

View File

@@ -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:

View File

@@ -94,13 +94,10 @@ class Screen(Widget):
def _on_update(self) -> None:
"""Called by the _update_timer."""
# Render widgets together
if self._dirty_widgets:
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 +106,8 @@ class Screen(Widget):
if not self.size:
return
# This paint the entire screen, so replaces the batched dirty widgets
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 +137,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()

View File

@@ -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)
@@ -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:

View File

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