mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #512 from Textualize/compositor-deltas
Compositor deltas
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
transition: color 300ms linear, background 300ms linear;
|
||||
}
|
||||
|
||||
|
||||
* {
|
||||
scrollbar-background: $panel-darken-2;
|
||||
scrollbar-background-hover: $panel-darken-3;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -208,7 +208,6 @@ def test_animator():
|
||||
|
||||
animator()
|
||||
assert animate_test.foo == 0
|
||||
assert animator._on_animation_frame_called
|
||||
|
||||
animator._time = 5
|
||||
animator()
|
||||
|
||||
Reference in New Issue
Block a user