Skip to content
Merged
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
100 changes: 84 additions & 16 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ class SubtreeMapGeometry(NamedTuple):

geometry: MapGeometry
clip_bounds: tuple[Region, ...]
parent: Widget | None
"""Original rendered parent within this capture, not the mutating DOM."""

@property
def region(self) -> Region:
Expand Down Expand Up @@ -228,22 +230,26 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]:
return spatial_map

@abstractmethod
def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False,
source_held: bool = False) -> bool:
...

def restore_into(
self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget],
key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip],
root: Widget, *, visible_only: bool, retained: set[Widget], bounds: Region,
source_held: bool = False,
) -> None:
self.project_into(geometry, key, clip, clips, root,
visible_only=visible_only, retained=retained, bounds=bounds)
visible_only=visible_only, retained=retained, bounds=bounds,
source_held=source_held)
widgets.update(self.widgets)
invisible_widgets.update(self.invisible_widgets)

def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey,
clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget,
*, visible_only: bool, retained: set[Widget], bounds: Region) -> None:
*, visible_only: bool, retained: set[Widget], bounds: Region,
source_held: bool = False) -> None:
"""Publish the requested scene without retiring complete source geometry.

A viewport needs exposed descendants and its explicit geometry targets.
Expand All @@ -260,7 +266,7 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey,
for _, node in sorted(candidates.items()))
else:
entries = ((node, entry) for node, (_, entry) in self.geometry.items())
for node, entry in entries:
for node, entry in self._paint_entries(entries, source_held=source_held):
required = not visible_only or node is root or node in retained
# Clip intersection can only reduce these original rectangle bounds.
# Reject an offscreen, unrequired entry before resolving its clip
Expand All @@ -272,6 +278,10 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey,
geometry[node] = placement
clips[node] = node_clip

def _paint_entries(self, entries, *, source_held: bool):
"""Placed sources retain their original native participation."""
return entries

@abstractmethod
def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey,
clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]:
Expand All @@ -287,7 +297,8 @@ def references_retired(self, owner: Widget, retired: set[Widget]) -> bool:
class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]):
"""A culled or screen-dependent arrangement keeps its exact placement."""

def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False,
source_held: bool = False) -> bool:
if self.key.visible_only and require_complete:
return False
return self.key == key or (
Expand Down Expand Up @@ -320,17 +331,47 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_
if scope is not clip:
return PlacedSubtreeGeometry.capture(
key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates)
intrinsic[node] = SubtreeMapGeometry(entry, bounds)
parent = node.parent
parent = parent._render_widget if isinstance(parent, Widget) else None
intrinsic[node] = SubtreeMapGeometry(
entry, bounds, parent if parent in geometry and parent is not node else None,
)
return super().capture(key, intrinsic, widgets, invisible_widgets,
clips, clip, screen_coordinates)

def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False,
source_held: bool = False) -> bool:
if source_held:
# The mutation owner lends the original source, not the changing
# DOM. Internal scroll and all arrangement inputs still agree;
# only source revisions belong to the ongoing mutation.
key = key._replace(geometry_revision=self.key.geometry_revision,
nodes_revision=self.key.nodes_revision)
return self.key.intrinsic() == key.intrinsic()

def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey,
clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]:
return entry.project(self.key, key, clip, root=root)

def _paint_entries(self, entries, *, source_held: bool):
if not source_held:
return entries
participation: dict[Widget, bool] = {}

def participates(node: Widget) -> bool:
if node not in participation:
parent = self.geometry[node][1].parent
participation[node] = node._render_widget is node and (
parent is None or (parent.is_container and participates(parent))
)
return participation[node]

# A retained-paint owner can now render itself rather than its old
# native children. Consult that declaration along captured ancestry,
# never walk or arrange the changing child tree. Each parent answers
# once within this projection; the resource remains immutable.
return ((node, entry) for node, entry in entries if participates(node))


class CompositorUpdate:
"""An update generated by the compositor, which also doubles as console renderables."""
Expand Down Expand Up @@ -885,7 +926,7 @@ def _arrange_root(
for node, geometry in previous.items():
for owner in mutation_roots.intersection(node.walk_ancestors(with_self=True)):
held[owner][node] = geometry
for owner in mutation_paths:
for owner in mutation_paths - mutation_roots:
self._subtree_geometry.pop(owner, None)
map: CompositorMap = {}
# Transient declaration paths manufacture the one retained resource.
Expand Down Expand Up @@ -916,7 +957,10 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None
default_layers = {"default": 0}
retained_paths: set[Widget] = set()
if visible_only:
for target in retain_geometry:
# A held root still needs its new outer placement when scrolling
# takes it offscreen. Its resource owns descendants; this acquires
# only the ancestor path, not the mutating subtree's arrangement.
for target in (*retain_geometry, *mutation_roots):
path: list[Widget] = []
node = target
# Existing members already own a path to this same root.
Expand Down Expand Up @@ -1144,15 +1188,39 @@ def arrange_widget(
def add_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, complete,
inherited_layers):
nonlocal map, widgets, invisible_widgets
if widget in held:
# Retain exact hit/paint placement. Moving or resizing this
# subtree belongs to the mutation owner's next layout.
for node, geometry in held[widget].items():
store_geometry(node, geometry, RootSceneClip(geometry.clip))
widgets.update(held[widget])
return
if inherited_layers is None and (order_names := widget._get_layer_order((widget,))) is not None:
inherited_layers = {name: index for index, name in enumerate(order_names)}
if widget in held:
resource = self._subtree_geometry.get(widget)
key = SubtreeGeometryKey.from_widget(
widget, virtual_region, region, order, layer_order, clip.region,
visible, dock_gutter, size, False,
tuple(inherited_layers.items()) if inherited_layers is not None else (),
)
if resource is not None and widget._render_widget in resource.geometry and resource.matches(
key, require_complete=True, source_held=True,
):
# Complete intrinsic geometry retains original descendants
# and clip declarations. Ancestor scrolling changes only
# their placement; no mutating DOM arrangement is read.
resource.restore_into(
map, widgets, invisible_widgets, key, clip, clips,
widget._render_widget, visible_only=visible_only and not complete,
retained=retained_paths, bounds=root_geometry.region,
source_held=True,
)
# The complete resource has replaced these old viewport
# entries, including nested holds. Do not merge them back
# after projection and resurrect stale absolute placement.
for owner in held.keys() & resource.widgets:
held[owner].clear()
else:
# Placed, resized or missing sources cannot authorize a
# projection. Keep their exact committed hit/paint bounds.
for node, geometry in held[widget].items():
store_geometry(node, geometry, RootSceneClip(geometry.clip))
widgets.update(held[widget])
return
if (widget in mutation_paths or not self.max_subtree_geometry_entries
or not widget.CACHE_SUBTREE_GEOMETRY or not widget._is_mounted):
arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter,
Expand Down
8 changes: 8 additions & 0 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,14 @@ def _invoke_and_clear_callbacks(self, roots: tuple[Widget, ...] | None = None) -
# None means not acquired; an empty borrowed tuple is a valid cohort.
if roots is None:
roots = tuple(root for _, roots in self._prepare_visible_screens() for root in roots) if self.is_current else ()
if self.is_current:
# Original retained pixels may paint during a source mutation.
# That publication does not complete the mutating sender's work.
# Acquire source custody once for this synchronous admission,
# separately from the original paint-readiness roots.
roots = (*roots, *(root
for screen in (*self.app._background_screens, self)
for root in screen._layout_mutation_roots()))
index = 0
for _ in range(len(self._callbacks)):
if self.app._batch_count or index >= len(self._callbacks):
Expand Down
6 changes: 5 additions & 1 deletion tests/test_batch_paint_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,11 @@ async def test_partial_publication_keeps_geometry_and_owner_callbacks():
await pilot.pause()
assert compositor.get_widget_at(15, 1)[0] is original_hit
holder.refresh(layout=True)
await asyncio.wait_for(scene_done.wait(), 2)
# Admission returns each callback to its original sender pump; the
# App callback cannot establish completion of the two widget tasks.
await asyncio.wait_for(asyncio.gather(
scene_done.wait(), held_done.wait(), parent_done.wait(),
), 2)
assert held_done.is_set() and parent_done.is_set()
assert screen.publications[-1] == ()
assert not compositor._dirty_regions
Expand Down
Loading
Loading