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;
|
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;
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user