From b2804bf101caf84b247eeba72c875eebe3a1ddfc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:16:28 -0400 Subject: [PATCH 1/9] Retain child geometry sources instead of flattening nested captures --- src/textual/_compositor.py | 138 +++++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 20 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e49134edde..9b95b5c695 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -12,7 +12,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from abc import ABC, abstractmethod from bisect import bisect_left, bisect_right from collections import Counter @@ -198,13 +198,35 @@ def project(self, original: SubtreeGeometryKey, current: SubtreeGeometryKey, GeometryEntry = TypeVar("GeometryEntry", MapGeometry, SubtreeMapGeometry) +@dataclass(frozen=True) +class SubtreeGeometryPlacement: + """A borrowed child source at its original acquired parent placement. + + The source retains its own geometry and lifetime. This edge owns only the + placement, clip declaration and captured parent in the containing source. + """ + + source: SubtreeGeometry + key: SubtreeGeometryKey + clip: SceneClip + parent: Widget | None = None + clip_bounds: tuple[Region, ...] = () + + @property + def region(self) -> Region: + """All child bounds, including overflow outside the root rectangle.""" + return self.source._spatial_map.total_region + ( + self.key.region.offset - self.source.key.region.offset + ) + + @dataclass(frozen=True) class SubtreeGeometry(ABC, Generic[GeometryEntry]): """One immutable native arrangement resource in the compositor's cache.""" key: SubtreeGeometryKey - geometry: Mapping[Widget, tuple[int, GeometryEntry]] - """Original source ordinals and placements, indexed by their native widget.""" + geometry: Mapping[Widget, tuple[int, GeometryEntry | SubtreeGeometryPlacement]] + """Ordered local placements and borrowed child sources, without flattening.""" widgets: frozenset[Widget] invisible_widgets: frozenset[Widget] @@ -219,6 +241,29 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ for ordinal, (node, entry) in enumerate(geometry.items()) }), widgets, invisible_widgets) + @staticmethod + def _contains_geometry(geometry, node: Widget) -> bool: + if node is None: + return False + return node in geometry or any( + isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) + for entry in geometry.values() + ) + + def contains(self, node: Widget) -> bool: + """Membership in this complete source, including borrowed children.""" + return node in self.geometry or any( + isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) + for _, entry in self.geometry.values() + ) + + def members(self, *, invisible: bool = False) -> Iterator[Widget]: + """Logical membership stays with each original source.""" + yield from self.invisible_widgets if invisible else self.widgets + for _, entry in self.geometry.values(): + if isinstance(entry, SubtreeGeometryPlacement): + yield from entry.source.members(invisible=invisible) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -243,8 +288,8 @@ def restore_into( self.project_into(geometry, key, clip, clips, root, visible_only=visible_only, retained=retained, bounds=bounds, source_held=source_held) - widgets.update(self.widgets) - invisible_widgets.update(self.invisible_widgets) + widgets.update(self.members()) + invisible_widgets.update(self.members(invisible=True)) def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, @@ -262,11 +307,26 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, for node in (root, *retained): if (indexed := self.geometry.get(node)) is not None: candidates[indexed[0]] = node + elif node in retained: + # Usually retained_paths includes the child root already. + # A held source may have a different captured ancestry. + for child, (ordinal, entry) in self.geometry.items(): + if (isinstance(entry, SubtreeGeometryPlacement) + and entry.source.contains(node)): + candidates[ordinal] = child entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) for node, entry in self._paint_entries(entries, source_held=source_held): + if isinstance(entry, SubtreeGeometryPlacement): + child_key, child_clip = self._project_child(entry, key, clip) + entry.source.project_into( + geometry, child_key, child_clip, clips, node, + visible_only=visible_only, retained=retained, bounds=bounds, + source_held=source_held, + ) + continue 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 @@ -282,6 +342,11 @@ def _paint_entries(self, entries, *, source_held: bool): """Placed sources retain their original native participation.""" return entries + def _project_child(self, entry: SubtreeGeometryPlacement, key: SubtreeGeometryKey, + clip: SceneClip) -> tuple[SubtreeGeometryKey, SceneClip]: + """Opaque sources retain the original exact child placement.""" + return entry.key, entry.clip + @abstractmethod def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: @@ -290,7 +355,10 @@ def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: return (owner in retired or not self.geometry.keys().isdisjoint(retired) or not self.widgets.isdisjoint(retired) - or not self.invisible_widgets.isdisjoint(retired)) + or not self.invisible_widgets.isdisjoint(retired) + or any(isinstance(entry, SubtreeGeometryPlacement) + and entry.source.references_retired(node, retired) + for node, (_, entry) in self.geometry.items())) @dataclass(frozen=True) @@ -310,7 +378,6 @@ def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: return entry, RootSceneClip(entry.clip) - @dataclass(frozen=True) class IntrinsicSubtreeGeometry(SubtreeGeometry[SubtreeMapGeometry]): """The same bounded resource, reusable under a new original outer clip.""" @@ -327,14 +394,23 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ intrinsic = {} origin = key.region.offset for node, entry in geometry.items(): - scope, bounds = clips[node].relative_bounds(clip, origin) + child = isinstance(entry, SubtreeGeometryPlacement) + scope, bounds = (entry.clip if child else clips[node]).relative_bounds(clip, origin) + if child and not isinstance(entry.source, IntrinsicSubtreeGeometry): + # A child whose source retains exact screen placement cannot + # authorize translation of its containing resource. + return PlacedSubtreeGeometry.capture( + key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) if scope is not clip: return PlacedSubtreeGeometry.capture( key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) 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, + parent = (parent if parent is not node and cls._contains_geometry(geometry, parent) + else None) + intrinsic[node] = ( + replace(entry, parent=parent, clip_bounds=bounds) if child + else SubtreeMapGeometry(entry, bounds, parent) ) return super().capture(key, intrinsic, widgets, invisible_widgets, clips, clip, screen_coordinates) @@ -353,6 +429,17 @@ 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 _project_child(self, entry, key, clip): + for bound in entry.clip_bounds: + clip = clip.intersect(bound + key.region.offset) + offset = key.region.offset - self.key.region.offset + return entry.key._replace( + region=entry.key.region + offset, + order=self.key.project_order(entry.key.order, key), + layer_order=entry.key.layer_order + key.layer_order - self.key.layer_order, + clip=clip.region, + ), clip + def _paint_entries(self, entries, *, source_held: bool): if not source_held: return entries @@ -928,7 +1015,8 @@ def _arrange_root( held[owner][node] = geometry for owner in mutation_paths - mutation_roots: self._subtree_geometry.pop(owner, None) - map: CompositorMap = {} + map: dict[Widget, MapGeometry | SubtreeGeometryPlacement] = {} + capturing_source = False # Transient declaration paths manufacture the one retained resource. # The published MapGeometry continues to own the actual clipped scene. clips: dict[Widget, SceneClip] = {} @@ -1187,7 +1275,7 @@ 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 + nonlocal map, widgets, invisible_widgets, capturing_source 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: @@ -1197,7 +1285,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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( + if resource is not None and resource.contains(widget._render_widget) and resource.matches( key, require_complete=True, source_held=True, ): # Complete intrinsic geometry retains original descendants @@ -1212,8 +1300,9 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # 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() + for owner in held: + if resource.contains(owner): + held[owner].clear() else: # Placed, resized or missing sources cannot authorize a # projection. Keep their exact committed hit/paint bounds. @@ -1247,8 +1336,11 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # Capture complete source geometry before its viewport publication. # Enclosing complete captures borrow all child entries; ordinary # viewport publication retains only exposed and required boxes. - parent_map, parent_widgets, parent_invisible = map, widgets, invisible_widgets + parent_map, parent_widgets, parent_invisible, parent_capture = ( + map, widgets, invisible_widgets, capturing_source + ) map, widgets, invisible_widgets = {}, set(), set() + capturing_source = True try: arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, complete, inherited_layers) # noqa: F821 -- closure cleared after traversal @@ -1256,15 +1348,21 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible added_widgets, added_invisible = frozenset(widgets), frozenset(invisible_widgets) finally: map, widgets, invisible_widgets = parent_map, parent_widgets, parent_invisible + capturing_source = parent_capture if (widget not in self._subtree_geometry and len(self._subtree_geometry) >= self.max_subtree_geometry_entries): self._subtree_geometry.pop(next(iter(self._subtree_geometry))) resource = resource_type.capture( key, geometry, added_widgets, added_invisible, clips, clip, screen_coordinates) self._subtree_geometry[widget] = resource - resource.restore_into(map, widgets, invisible_widgets, key, clip, clips, widget._render_widget, - visible_only=visible_only and not enclosing_complete, - retained=retained_paths, bounds=root_geometry.region) + if capturing_source: + # Retain the child source and its placement. A containing + # capture does not consume a second flat copy of descendants. + map[widget._render_widget] = SubtreeGeometryPlacement(resource, key, clip) + else: + resource.restore_into(map, widgets, invisible_widgets, key, clip, clips, widget._render_widget, + visible_only=visible_only and not enclosing_complete, + retained=retained_paths, bounds=root_geometry.region) # Add top level (root) widget previous_layout_geometry = self._layout_geometry @@ -1298,7 +1396,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible map.update(geometry) widgets.update(geometry) widgets -= invisible_widgets - return map, widgets + return cast(CompositorMap, map), widgets @property def layers(self) -> list[tuple[Widget, MapGeometry]]: From 58cce5c71b8e8b7fb12af0d574c81523ccbe31d1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:28:05 -0400 Subject: [PATCH 2/9] Preserve required reader and held ancestry semantics across child sources --- src/textual/_compositor.py | 30 +++++--- tests/test_subtree_geometry_cache.py | 95 +++++++++++++++++++++++++ tests/test_viewport_geometry_targets.py | 12 ++-- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 9b95b5c695..4bd061f12e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -242,21 +242,31 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ }), widgets, invisible_widgets) @staticmethod - def _contains_geometry(geometry, node: Widget) -> bool: + def _contains_geometry(geometry, entries, node: Widget) -> bool: if node is None: return False return node in geometry or any( isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for entry in geometry.values() + for entry in entries ) def contains(self, node: Widget) -> bool: """Membership in this complete source, including borrowed children.""" - return node in self.geometry or any( - isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for _, entry in self.geometry.values() + return self._contains_geometry( + self.geometry, (entry for _, entry in self.geometry.values()), node ) + def captured_parent(self, node: Widget) -> Widget | None: + """The original parent edge, independent of the live child tree.""" + if (indexed := self.geometry.get(node)) is not None: + entry = indexed[1] + return (entry.parent if isinstance(entry, (SubtreeMapGeometry, SubtreeGeometryPlacement)) + else None) + for _, entry in self.geometry.values(): + if isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node): + return entry.source.captured_parent(node) + raise errors.NoWidget("Widget is not in captured subtree") + def members(self, *, invisible: bool = False) -> Iterator[Widget]: """Logical membership stays with each original source.""" yield from self.invisible_widgets if invisible else self.widgets @@ -294,7 +304,7 @@ def restore_into( def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, *, visible_only: bool, retained: set[Widget], bounds: Region, - source_held: bool = False) -> None: + source_held: bool = False, require_root: bool = True) -> None: """Publish the requested scene without retiring complete source geometry. A viewport needs exposed descendants and its explicit geometry targets. @@ -319,15 +329,15 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) for node, entry in self._paint_entries(entries, source_held=source_held): + required = not visible_only or (node is root and require_root) or node in retained if isinstance(entry, SubtreeGeometryPlacement): child_key, child_clip = self._project_child(entry, key, clip) entry.source.project_into( geometry, child_key, child_clip, clips, node, visible_only=visible_only, retained=retained, bounds=bounds, - source_held=source_held, + source_held=source_held, require_root=required, ) continue - 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 # chain, descendant rank and destination MapGeometry allocation. @@ -406,7 +416,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) parent = node.parent parent = parent._render_widget if isinstance(parent, Widget) else None - parent = (parent if parent is not node and cls._contains_geometry(geometry, parent) + parent = (parent if parent is not node and cls._contains_geometry(geometry, geometry.values(), parent) else None) intrinsic[node] = ( replace(entry, parent=parent, clip_bounds=bounds) if child @@ -447,7 +457,7 @@ def _paint_entries(self, entries, *, source_held: bool): def participates(node: Widget) -> bool: if node not in participation: - parent = self.geometry[node][1].parent + parent = self.captured_parent(node) participation[node] = node._render_widget is node and ( parent is None or (parent.is_container and participates(parent)) ) diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 603db7e9e4..fcd78feaae 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -52,6 +52,101 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i assert not compositor._subtree_geometry +async def test_changed_parent_borrows_original_children_without_flat_capture(): + from textual._compositor import SubtreeGeometryPlacement + from textual.containers import VerticalScroll + from textual.screen import Screen + + rows = [CachedGroup(Static(f"row {index}"), Static("wrapped " * 7)) + for index in range(20)] + history = CachedGroup(*rows) + + class SourceScreen(Screen): + def compose(self): + yield VerticalScroll(history) + + def _layout_mutation_roots(self): + return (history,) if history.lock.is_locked else () + + def _prepare_compositor_refresh(self): + return self._layout_mutation_roots() + + class SourceApp(App): + CSS = "VerticalScroll { height: 10; } Static { height: auto; }" + + def get_default_screen(self): + return SourceScreen() + + app = SourceApp() + async with app.run_test(size=(30, 12)) as pilot: + await pilot.pause() + compositor = app.screen._compositor + original = compositor._subtree_geometry[history] + stable = rows[5] + stable_source = original.geometry[stable][1] + assert isinstance(stable_source, SubtreeGeometryPlacement) + child = stable.children[1] + assert child not in original.geometry + assert original.contains(child) + assert original.captured_parent(child) is stable + + rows[0].children[0].update("changed\nheight\nthree") + inserted = CachedGroup(Static("new row"), Static("new second child")) + await history.mount(inserted, before=rows[1]) + await pilot.pause() + current = compositor._subtree_geometry[history] + assert current is not original + assert current.geometry[stable][1].source is stable_source.source + assert len(current.geometry) == len(history.children) + 1 + assert child not in current.geometry + assert current.contains(child) + scroll = app.query_one(VerticalScroll) + expected = {} + for position in (0, 18, 3, 55): + scroll.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + reference = Compositor(max_subtree_geometry_entries=0) + reference.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + demanded = set(child.walk_ancestors(with_self=True)) + # Cached complete sources may retain extra empty clipped boxes. + # Visible paint and every explicitly required reader must agree. + def required_scene(source): + return {node: geometry for node, geometry in source._published_map.items() + if geometry.visible_region or node in demanded} + assert required_scene(compositor) == required_scene(reference) + expected[position] = required_scene(reference) + # Complete source membership includes offscreen descendants; + # uncached visible traversal intentionally does not visit them. + assert compositor.widgets == reference._arrange_root( + app.screen, app.size, visible_only=False + )[1] + assert compositor.find_widget(child) == reference.find_widget(child) + held_source = compositor._subtree_geometry[history] + async with history.lock: + incomplete = CachedGroup(Static("NOT_COMMITTED")) + await history.mount(incomplete) + await pilot.pause() + for position in (3, 18, 55, 0): + scroll.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + assert compositor._subtree_geometry[history] is held_source + assert not held_source.contains(incomplete) + assert incomplete not in compositor._published_map + assert required_scene(compositor) == expected[position] + await incomplete.remove() + await pilot.pause() + compositor.reflow(app.screen, app.size) + reference.reflow(app.screen, app.size) + assert compositor._published_map == reference._published_map + await stable.remove() + await pilot.pause() + assert stable not in compositor._subtree_geometry + assert not any(source.contains(child) for source in compositor._subtree_geometry.values()) + assert child not in compositor._published_map + + @pytest.mark.parametrize("capacity", [-1, True, 1.5, None]) def test_geometry_budget_rejects_invalid_capacities(capacity): with pytest.raises(ValueError, match="non-negative integer"): diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 8dceadfa67..94d6339222 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -106,7 +106,7 @@ def compose(self): compositor = screen._compositor resource = compositor._subtree_geometry[body] assert isinstance(resource, IntrinsicSubtreeGeometry) - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) # Holding revisions does not permit resizing or changing the source's # own scroll scope. Those answers still require a fresh arrangement. assert not resource.matches( @@ -215,7 +215,7 @@ def _layout_mutation_roots(self): screen.query_one("#history").scroll_to(y=10, animate=False, immediate=True) await pilot.pause() for node, geometry in original.items(): - if node in resource.geometry: + if resource.contains(node): assert compositor._published_map[node] == geometry await retired.remove() await pilot.pause() @@ -275,8 +275,8 @@ def _layout_mutation_roots(self): await pilot.pause() compositor = screen._compositor resource = compositor._subtree_geometry[holder] - assert old in resource.geometry - assert resource.geometry[old][1].parent is body + assert resource.contains(old) + assert resource.captured_parent(old) is body async with holder.lock: body.update(Content("\n".join(["ACQUIRED_ROOT_PAINT"] * 18))) await body.mount(ChangingChild("UNCOMMITTED_DESCENDANT")) @@ -369,7 +369,7 @@ def compose(self): history = screen.query_one("#history", VerticalScroll) compositor = screen._compositor resource = compositor._subtree_geometry[body] - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) for position in (0, 200, 0): history.scroll_to(y=position, immediate=True, animate=False) @@ -378,7 +378,7 @@ def compose(self): assert body in viewport and rows[235] in viewport omitted = rows[200] if position == 0 else rows[0] assert omitted not in viewport - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) assert all(row in compositor.widgets for row in rows) _, placement = next(compositor.published_geometry((body,))) source_bounds = app.size.region - (placement.region.offset - resource.key.region.offset) From 547e42acc6ab91f640932c3c7efcb3fe69798301 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:51:06 -0400 Subject: [PATCH 3/9] Route reader demands and held source loans through child edges --- src/textual/_compositor.py | 57 ++++++++++++++++++++++------ tests/test_subtree_geometry_cache.py | 26 +++++++++++-- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4bd061f12e..943f8a404f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -211,6 +211,8 @@ class SubtreeGeometryPlacement: clip: SceneClip parent: Widget | None = None clip_bounds: tuple[Region, ...] = () + source_held: bool = False + """This edge borrows mutation-owned participation for one publication.""" @property def region(self) -> Region: @@ -274,6 +276,19 @@ def members(self, *, invisible: bool = False) -> Iterator[Widget]: if isinstance(entry, SubtreeGeometryPlacement): yield from entry.source.members(invisible=invisible) + @cached_property + def reusable(self) -> bool: + """Whether this immutable source can supply ordinary reuse. + + A captured loan needs fresh acquisition unless the containing mutation + owner explicitly lends that original whole-source snapshot. + """ + return all( + not entry.source_held and entry.source.reusable + for _, entry in self.geometry.values() + if isinstance(entry, SubtreeGeometryPlacement) + ) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -312,30 +327,39 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, entry. Both newly captured and reused resources publish through here. """ source_bounds = bounds - (key.region.offset - self.key.region.offset) + child_demands: dict[Widget, set[Widget]] = {} if visible_only: candidates = dict(self._spatial_map.get_values_in_region(source_bounds)) - for node in (root, *retained): + if (indexed_root := self.geometry.get(root)) is not None: + candidates[indexed_root[0]] = root + for node in retained: if (indexed := self.geometry.get(node)) is not None: candidates[indexed[0]] = node - elif node in retained: - # Usually retained_paths includes the child root already. - # A held source may have a different captured ancestry. + if isinstance(indexed[1], SubtreeGeometryPlacement): + child_demands.setdefault(node, set()).add(node) + else: + # Route once through captured membership. Children receive + # their own demands, never the unrelated global path set. + # More than one edge may contain an original widget; keep + # source order and its original last-assignment semantics. for child, (ordinal, entry) in self.geometry.items(): if (isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node)): candidates[ordinal] = child + child_demands.setdefault(child, set()).add(node) entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) + no_demand: set[Widget] = set() for node, entry in self._paint_entries(entries, source_held=source_held): required = not visible_only or (node is root and require_root) or node in retained if isinstance(entry, SubtreeGeometryPlacement): child_key, child_clip = self._project_child(entry, key, clip) entry.source.project_into( geometry, child_key, child_clip, clips, node, - visible_only=visible_only, retained=retained, bounds=bounds, - source_held=source_held, require_root=required, + visible_only=visible_only, retained=child_demands.get(node, no_demand), bounds=bounds, + source_held=source_held or entry.source_held, require_root=required, ) continue # Clip intersection can only reduce these original rectangle bounds. @@ -377,6 +401,8 @@ class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: + if not self.reusable and not source_held: + return False if self.key.visible_only and require_complete: return False return self.key == key or ( @@ -427,6 +453,8 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: + if not self.reusable and not source_held: + return False if source_held: # The mutation owner lends the original source, not the changing # DOM. Internal scroll and all arrangement inputs still agree; @@ -1301,12 +1329,17 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # 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, - ) + if capturing_source: + map[widget._render_widget] = SubtreeGeometryPlacement( + resource, key, clip, source_held=True + ) + else: + 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. diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index fcd78feaae..4ae1809e49 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -53,6 +53,7 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i async def test_changed_parent_borrows_original_children_without_flat_capture(): + from dataclasses import replace from textual._compositor import SubtreeGeometryPlacement from textual.containers import VerticalScroll from textual.screen import Screen @@ -89,6 +90,22 @@ def get_default_screen(self): assert child not in original.geometry assert original.contains(child) assert original.captured_parent(child) is stable + # Capture the same acquired source with one original child loan. The + # immutable lifetime must be refused at the parent's reuse owner. + incoming_clip = stable_source.clip.source + local = {node: (entry if isinstance(entry, SubtreeGeometryPlacement) else entry.geometry) + for node, (_, entry) in original.geometry.items()} + local[stable] = replace(stable_source, source_held=True) + loan = type(original).capture( + original.key, local, original.widgets, original.invisible_widgets, + {history: incoming_clip}, incoming_clip, set(), + ) + assert loan.geometry[stable][1].source is stable_source.source + assert loan.geometry[stable][1].source_held + assert not loan.reusable + assert not loan.matches(loan.key) + assert loan.matches(loan.key, source_held=True) + assert original.reusable rows[0].children[0].update("changed\nheight\nthree") inserted = CachedGroup(Static("new row"), Static("new second child")) @@ -101,14 +118,15 @@ def get_default_screen(self): assert child not in current.geometry assert current.contains(child) scroll = app.query_one(VerticalScroll) + targets = (child, rows[12].children[0], rows[-1].children[-1]) expected = {} for position in (0, 18, 3, 55): scroll.scroll_to(y=position, animate=False, immediate=True) await pilot.pause() - compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + compositor.reflow_visible(app.screen, app.size, retain_geometry=targets) reference = Compositor(max_subtree_geometry_entries=0) - reference.reflow_visible(app.screen, app.size, retain_geometry=(child,)) - demanded = set(child.walk_ancestors(with_self=True)) + reference.reflow_visible(app.screen, app.size, retain_geometry=targets) + demanded = {node for target in targets for node in target.walk_ancestors(with_self=True)} # Cached complete sources may retain extra empty clipped boxes. # Visible paint and every explicitly required reader must agree. def required_scene(source): @@ -130,7 +148,7 @@ def required_scene(source): for position in (3, 18, 55, 0): scroll.scroll_to(y=position, animate=False, immediate=True) await pilot.pause() - compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + compositor.reflow_visible(app.screen, app.size, retain_geometry=targets) assert compositor._subtree_geometry[history] is held_source assert not held_source.contains(incomplete) assert incomplete not in compositor._published_map From d8ebcd9dd54bf9647e683758973cc416b96caa4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 11:06:24 -0400 Subject: [PATCH 4/9] Preserve partial held acquisition scope through composed sources --- src/textual/_compositor.py | 36 ++++++++++++++++++++++++---- tests/test_subtree_geometry_cache.py | 18 +++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 943f8a404f..7ec4c98192 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -289,6 +289,14 @@ def reusable(self) -> bool: if isinstance(entry, SubtreeGeometryPlacement) ) + @cached_property + def complete(self) -> bool: + """Complete acquisition includes the scope of every borrowed source.""" + return not self.key.visible_only and all( + entry.source.complete for _, entry in self.geometry.values() + if isinstance(entry, SubtreeGeometryPlacement) + ) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -403,7 +411,7 @@ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: if not self.reusable and not source_held: return False - if self.key.visible_only and require_complete: + if require_complete and not self.complete: return False return self.key == key or ( not self.key.visible_only and key.visible_only @@ -455,6 +463,8 @@ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: if not self.reusable and not source_held: return False + if require_complete and not self.complete: + return False if source_held: # The mutation owner lends the original source, not the changing # DOM. Internal scroll and all arrangement inputs still agree; @@ -1349,9 +1359,27 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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]) + if capturing_source: + # The published fallback is a partial placed source, + # not an invented complete or translatable subtree. + # Keep that lending scope on the same child edge. + snapshot = held[widget] + partial_key = SubtreeGeometryKey.from_widget( + widget, virtual_region, region, order, layer_order, + clip.region, visible, dock_gutter, size, True, + tuple(inherited_layers.items()) if inherited_layers is not None else (), + ) + source = PlacedSubtreeGeometry.capture( + partial_key, snapshot, frozenset(snapshot), frozenset(), + clips, clip, screen_coordinates, + ) + map[widget._render_widget] = SubtreeGeometryPlacement( + source, partial_key, clip, source_held=True + ) + else: + 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): diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 4ae1809e49..03e7ec30ad 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -54,7 +54,7 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i async def test_changed_parent_borrows_original_children_without_flat_capture(): from dataclasses import replace - from textual._compositor import SubtreeGeometryPlacement + from textual._compositor import PlacedSubtreeGeometry, SubtreeGeometryPlacement from textual.containers import VerticalScroll from textual.screen import Screen @@ -106,6 +106,22 @@ def get_default_screen(self): assert not loan.matches(loan.key) assert loan.matches(loan.key, source_held=True) assert original.reusable + # A missing complete child source can lend only its known placed + # snapshot. A containing capture must not promote that scope. + partial = PlacedSubtreeGeometry.capture( + stable_source.key._replace(visible_only=True), + {stable: stable_source.source.geometry[stable][1].geometry}, + frozenset({stable}), frozenset(), {}, stable_source.clip, set(), + ) + local[stable] = replace(stable_source, source=partial, source_held=True) + partial_loan = type(original).capture( + original.key, local, original.widgets, original.invisible_widgets, + {history: incoming_clip}, incoming_clip, set(), + ) + assert partial_loan.contains(stable) + assert not partial_loan.contains(child) + assert not partial_loan.complete + assert not partial_loan.matches(partial_loan.key, require_complete=True, source_held=True) rows[0].children[0].update("changed\nheight\nthree") inserted = CachedGroup(Static("new row"), Static("new second child")) From bb625cb653b70d6ab366ece85e8021e8fd0d3728 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 13:13:58 -0400 Subject: [PATCH 5/9] Preserve declared text flags through native visual style conversion --- src/textual/style.py | 34 ++++++++++++------------ src/textual/widget.py | 26 +++++++------------ tests/test_style_inheritance.py | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/src/textual/style.py b/src/textual/style.py index e14384d69b..637058bb83 100644 --- a/src/textual/style.py +++ b/src/textual/style.py @@ -7,7 +7,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import cached_property, lru_cache from operator import attrgetter from pickle import dumps, loads @@ -38,6 +38,7 @@ "link", "auto_color", "_meta", + "overline", ) @@ -54,22 +55,9 @@ "blink", "link", "_meta", + "overline", ) -_get_simple_attributes_sans_color = attrgetter( - "bold", - "dim", - "italic", - "underline", - "underline2", - "reverse", - "strike", - "blink", - "link", - "_meta", -) - - _get_attributes = attrgetter( "background", "foreground", @@ -109,6 +97,7 @@ class Style: link: str | None = None _meta: bytes | None = None auto_color: bool = False + overline: bool | None = None def __rich_repr__(self) -> rich.repr.Result: yield "background", self.background, None @@ -121,6 +110,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "reverse", self.reverse, None yield "strike", self.strike, None yield "blink", self.blink, None + yield "overline", self.overline, None yield "link", self.link, None if self._meta is not None: @@ -141,6 +131,7 @@ def _is_null(self) -> bool: None, None, None, + None, ) @cached_property @@ -185,6 +176,8 @@ def style_definition(self) -> str: output_append("strike" if self.strike else "not strike") if self.blink is not None: output_append("blink" if self.blink else "not blink") + if self.overline is not None: + output_append("overline" if self.overline else "not overline") if self.link is not None: if "'" not in self.link: output_append(f"link='{self.link}'") @@ -227,6 +220,8 @@ def markup_tag(self) -> str: output_append("strike" if self.strike else "not strike") if self.blink is not None: output_append("blink" if self.blink else "not blink") + if self.overline is not None: + output_append("overline" if self.overline else "not overline") if self.link is not None: output_append("link") if self._meta is not None: @@ -300,6 +295,7 @@ def __add__(self, other: object | None) -> Style: if _meta is not None and other__meta is not None else (_meta if other__meta is None else other__meta) ), + overline=self.overline if other.overline is None else other.overline, ) return new_style elif other is None: @@ -384,6 +380,7 @@ def from_rich_style( blink=rich_style.blink, link=rich_style.link, _meta=rich_style._meta, + overline=rich_style.overline, ) @classmethod @@ -411,6 +408,7 @@ def from_styles(cls, styles: StylesBase) -> Style: strike=text_style.strike, blink=text_style.blink, auto_color=styles.auto_color, + overline=text_style.overline, ) @classmethod @@ -446,6 +444,7 @@ def rich_style(self) -> RichStyle: blink, link, _meta, + overline, ) = _get_simple_attributes(self) color = None if foreground is None else background + foreground @@ -463,6 +462,7 @@ def rich_style(self) -> RichStyle: blink=blink, link=link, meta=None if _meta is None else self.meta, + overline=overline, ) def rich_style_with_offset(self, x: int, y: int) -> RichStyle: @@ -490,6 +490,7 @@ def rich_style_with_offset(self, x: int, y: int) -> RichStyle: blink, link, _meta, + overline, ) = _get_simple_attributes(self) color = None if foreground is None else background + foreground return RichStyle( @@ -505,12 +506,13 @@ def rich_style_with_offset(self, x: int, y: int) -> RichStyle: blink=blink, link=link, meta={**self.meta, "offset": (x, y)}, + overline=overline, ) @cached_property def without_color(self) -> Style: """The style without any colors.""" - return Style(None, None, *_get_simple_attributes_sans_color(self)) + return replace(self, background=None, foreground=None, auto_color=False) @cached_property def background_style(self) -> Style: diff --git a/src/textual/widget.py b/src/textual/widget.py index dcdbb95e11..222a0d82c4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -8,6 +8,7 @@ from asyncio import Task, create_task, gather, shield from collections import Counter from contextlib import asynccontextmanager +from dataclasses import replace from fractions import Fraction from time import monotonic from types import TracebackType @@ -1325,14 +1326,10 @@ def iter_styles() -> Iterable[StylesBase]: if has_rule("auto_color") and styles.auto_color: color = text_background.get_contrast_text(color.a) - visual_style = VisualStyle( - background, - color, - bold=style.bold, - dim=style.dim, - italic=style.italic, - underline=style.underline, - strike=style.strike, + visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, ) self._visual_style_cache[cache_key] = visual_style @@ -4501,15 +4498,10 @@ def visual_style(self) -> VisualStyle: or not resolved.same_paint(self._visual_paint_state) ): background, color, style = resolved.background, resolved.foreground, resolved.text_style - self._visual_style = VisualStyle( - background, - color, - bold=style.bold, - dim=style.dim, - italic=style.italic, - reverse=style.reverse, - underline=style.underline, - strike=style.strike, + self._visual_style = replace( + VisualStyle.from_rich_style(style), + background=background, + foreground=color, ) self._visual_paint_state = resolved return self._visual_style diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py index 72b631a94a..d0b5b09439 100644 --- a/tests/test_style_inheritance.py +++ b/tests/test_style_inheritance.py @@ -2,6 +2,52 @@ from textual.widgets import Button, Static +def test_visual_style_preserves_declared_flags(): + from rich.style import Style as RichStyle + from textual.style import Style + + original = RichStyle(reverse=True, underline2=True, blink=True, overline=True) + converted = Style.from_rich_style(original) + assert converted.rich_style == original + assert converted.without_color.rich_style == original + assert converted.rich_style_with_offset(2, 3).overline is True + assert (converted + Style(overline=False, reverse=False)).rich_style.overline is False + assert (converted + Style(overline=False, reverse=False)).rich_style.reverse is False + assert Style(overline=True) != Style() + assert not Style(overline=True)._is_null + assert Style(overline=True).style_definition == "overline" + + +async def test_component_and_widget_render_keep_declared_text_flags(): + from textual.widgets import OptionList + + class StyleApp(App): + CSS = """ + OptionList > .option-list--option-highlighted { + text-style: reverse blink overline; + } + Static { text-style: reverse blink overline; } + """ + + def compose(self): + yield OptionList("selected") + yield Static("sidebar", id="row") + + async with StyleApp().run_test(size=(40, 12)) as pilot: + await pilot.pause() + options = pilot.app.query_one(OptionList) + row = pilot.app.query_one("#row", Static) + component = options.get_visual_style("option-list--option-highlighted") + for style in (component.rich_style, row.visual_style.rich_style): + assert style.reverse and style.blink and style.overline + for widget, word in ((options, "selected"), (row, "sidebar")): + segments = [segment for strip in widget.render_lines(widget.region.reset_offset) + for segment in strip if word in segment.text] + assert segments + assert all(segment.style.reverse and segment.style.blink + and segment.style.overline for segment in segments) + + async def test_text_style_inheritance(): """Check that changes to text style are inherited in children.""" From dba950bd586fff53b29d53520dbdfd42ca56d9cb Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:07:00 -0400 Subject: [PATCH 6/9] Join Markdown stream drain without cancelling an unstarted worker --- src/textual/widgets/_markdown.py | 21 ++++++++++----- tests/test_markdown.py | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 885a863bc3..b65300a249 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -71,11 +71,19 @@ def start(self) -> None: async def stop(self) -> None: """Stop the stream and await its finish.""" - if self._task is not None: - self._task.cancel() - await self._task - self._task = None - self._stopped = True + self._stopped = True + self._new_markup.set() + task = self._task + if task is not None: + try: + await asyncio.shield(task) + except asyncio.CancelledError: + # Caller cancellation must not abandon the stream's append drain. + await asyncio.shield(task) + raise + finally: + if task.done() and self._task is task: + self._task = None async def write(self, markdown_fragment: str) -> None: """Append or enqueue a markdown fragment. @@ -97,7 +105,8 @@ async def write(self, markdown_fragment: str) -> None: async def _run(self) -> None: """Run a task to append markdown fragments when available.""" try: - while await self._new_markup.wait(): + while not self._stopped or self._pending: + await self._new_markup.wait() new_markdown = "".join(self._pending) self._pending.clear() self._new_markup.clear() diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 502f3d2098..601be30b08 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from pathlib import Path from typing import Iterator @@ -42,6 +43,51 @@ def compose(self) -> ComposeResult: yield FussyMarkdown(self._markdown) +async def test_stream_immediate_stop_joins_unstarted_worker(): + document = Markdown() + async with App().run_test() as pilot: + await pilot.app.mount(document) + stream = document.get_stream(document) + task = stream._task + await stream.stop() + assert task.done() and not task.cancelled() + assert stream._task is None and stream._stopped + await stream.stop() + with pytest.raises(RuntimeError): + await stream.write("late") + + +async def test_stream_stop_drains_real_append_before_propagating_caller_cancellation(): + entered = asyncio.Event() + release = asyncio.Event() + + class HeldMarkdown(Markdown): + async def append(self, fragment): + entered.set() + await release.wait() + await super().append(fragment) + + document = HeldMarkdown() + async with App().run_test() as pilot: + await pilot.app.mount(document) + stream = document.get_stream(document) + await stream.write("first\n\n") + await entered.wait() + await stream.write("second") + worker = stream._task + stopping = asyncio.create_task(stream.stop()) + await asyncio.sleep(0) + stopping.cancel() + await asyncio.sleep(0) + assert not stopping.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await stopping + assert worker.done() and not worker.cancelled() + assert stream._task is None and stream._stopped + assert document._markdown == "first\n\nsecond" + + async def test_document_supplies_inline_content_for_heading_paragraph_and_table(): class CustomParagraph(MD.MarkdownParagraph): def _token_to_content(self, token: Token) -> Content: From be8ddeb4fef4d0dcb784ae5ae0a2f7348a807ebf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:12:58 -0400 Subject: [PATCH 7/9] Stop empty Markdown streams without publishing an empty append --- src/textual/widgets/_markdown.py | 3 ++- tests/test_markdown.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index b65300a249..a9d38b668b 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -110,7 +110,8 @@ async def _run(self) -> None: new_markdown = "".join(self._pending) self._pending.clear() self._new_markup.clear() - await asyncio.shield(self.markdown_widget.append(new_markdown)) + if new_markdown: + await asyncio.shield(self.markdown_widget.append(new_markdown)) except asyncio.CancelledError: # Task has been cancelled, add any outstanding markdown pass diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 601be30b08..5d28aac93b 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -43,13 +43,24 @@ def compose(self) -> ComposeResult: yield FussyMarkdown(self._markdown) -async def test_stream_immediate_stop_joins_unstarted_worker(): - document = Markdown() +@pytest.mark.parametrize("parked", [False, True]) +async def test_stream_empty_stop_joins_without_publication(parked): + appends = [] + + class ObservedMarkdown(Markdown): + async def append(self, fragment): + appends.append(fragment) + await super().append(fragment) + + document = ObservedMarkdown() async with App().run_test() as pilot: await pilot.app.mount(document) stream = document.get_stream(document) task = stream._task + if parked: + await asyncio.sleep(0) await stream.stop() + assert appends == [] assert task.done() and not task.cancelled() assert stream._task is None and stream._stopped await stream.stop() From 0fbb915dbabcc0912f491784fc901458643ad71e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:29:11 -0400 Subject: [PATCH 8/9] Route retained geometry demands through immutable source membership --- src/textual/_compositor.py | 47 +++++++++++++--------- tests/test_viewport_geometry_targets.py | 52 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7ec4c98192..0c9ab27530 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -244,19 +244,28 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ }), widgets, invisible_widgets) @staticmethod - def _contains_geometry(geometry, entries, node: Widget) -> bool: - if node is None: - return False - return node in geometry or any( - isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for entry in entries - ) + def _route_geometry(geometry) -> Mapping[Widget, tuple[Widget, ...]]: + """Index original geometry membership by its ordered borrowed edges. + + Logical and invisible membership do not imply a geometry placement. + A widget may belong to multiple child sources; every edge remains in + original order so projection keeps its last-assignment semantics. + """ + routes: dict[Widget, list[Widget]] = {node: [] for node in geometry} + for child, entry in geometry.items(): + if isinstance(entry, SubtreeGeometryPlacement): + for node in entry.source._geometry_routes: + routes.setdefault(node, []).append(child) + return MappingProxyType({node: tuple(children) for node, children in routes.items()}) + + @cached_property + def _geometry_routes(self) -> Mapping[Widget, tuple[Widget, ...]]: + """Geometry routing belongs to this immutable source, not the DOM.""" + return self._route_geometry({node: entry for node, (_, entry) in self.geometry.items()}) def contains(self, node: Widget) -> bool: """Membership in this complete source, including borrowed children.""" - return self._contains_geometry( - self.geometry, (entry for _, entry in self.geometry.values()), node - ) + return node in self._geometry_routes def captured_parent(self, node: Widget) -> Widget | None: """The original parent edge, independent of the live child tree.""" @@ -264,9 +273,9 @@ def captured_parent(self, node: Widget) -> Widget | None: entry = indexed[1] return (entry.parent if isinstance(entry, (SubtreeMapGeometry, SubtreeGeometryPlacement)) else None) - for _, entry in self.geometry.values(): - if isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node): - return entry.source.captured_parent(node) + if children := self._geometry_routes.get(node): + entry = self.geometry[children[0]][1] + return entry.source.captured_parent(node) raise errors.NoWidget("Widget is not in captured subtree") def members(self, *, invisible: bool = False) -> Iterator[Widget]: @@ -350,11 +359,10 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, # their own demands, never the unrelated global path set. # More than one edge may contain an original widget; keep # source order and its original last-assignment semantics. - for child, (ordinal, entry) in self.geometry.items(): - if (isinstance(entry, SubtreeGeometryPlacement) - and entry.source.contains(node)): - candidates[ordinal] = child - child_demands.setdefault(child, set()).add(node) + for child in self._geometry_routes.get(node, ()): + ordinal = self.geometry[child][0] + candidates[ordinal] = child + child_demands.setdefault(child, set()).add(node) entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: @@ -437,6 +445,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) intrinsic = {} origin = key.region.offset + geometry_routes = cls._route_geometry(geometry) for node, entry in geometry.items(): child = isinstance(entry, SubtreeGeometryPlacement) scope, bounds = (entry.clip if child else clips[node]).relative_bounds(clip, origin) @@ -450,7 +459,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) parent = node.parent parent = parent._render_widget if isinstance(parent, Widget) else None - parent = (parent if parent is not node and cls._contains_geometry(geometry, geometry.values(), parent) + parent = (parent if parent is not node and parent in geometry_routes else None) intrinsic[node] = ( replace(entry, parent=parent, clip_bounds=bounds) if child diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 94d6339222..e53b24dc78 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -8,6 +8,58 @@ from textual.widgets import Static +def test_borrowed_geometry_routes_preserve_membership_ancestry_and_assignment_order(): + from types import MappingProxyType + + from textual._compositor import ( + IntrinsicSubtreeGeometry, PlacedSubtreeGeometry, RootSceneClip, + SubtreeGeometryKey, SubtreeGeometryPlacement, SubtreeMapGeometry, + ) + from textual.geometry import Offset, Region, Size, Spacing + from textual.map_geometry import MapGeometry + + root, first, second, shared, logical, invisible, unrelated = ( + Static(name) for name in ("root", "first", "second", "shared", "logical", "invisible", "unrelated") + ) + bounds = Region(0, 0, 20, 10) + clip = RootSceneClip(bounds) + key = SubtreeGeometryKey(0, 0, bounds, bounds, (), 0, bounds, True, + Spacing(), bounds.size, False, Offset(), ()) + + def placed(y): + region = Region(0, y, 5, 1) + return MapGeometry(region, (), bounds, region.size, region.size, region, Spacing()) + + def source(owner, y): + return IntrinsicSubtreeGeometry( + key, MappingProxyType({ + owner: (0, SubtreeMapGeometry(placed(y), (), None)), + shared: (1, SubtreeMapGeometry(placed(y + 1), (), owner)), + }), frozenset((owner, shared, logical)), frozenset((invisible,)), + ) + + first_source, second_source = source(first, 0), source(second, 5) + resource = PlacedSubtreeGeometry( + key, MappingProxyType({ + first: (0, SubtreeGeometryPlacement(first_source, key, clip, root)), + second: (1, SubtreeGeometryPlacement(second_source, key, clip, root)), + root: (2, placed(9)), + }), frozenset((root,)), frozenset(), + ) + assert resource.contains(shared) + assert logical in set(resource.members()) and not resource.contains(logical) + assert invisible in set(resource.members(invisible=True)) and not resource.contains(invisible) + assert not resource.contains(unrelated) + assert resource._geometry_routes[shared] == (first, second) + assert resource.captured_parent(shared) is first + assert resource.captured_parent(first) is root + published, clips = {}, {} + resource.project_into(published, key, clip, clips, root, visible_only=True, + retained={shared, unrelated}, bounds=Region(30, 30, 1, 1)) + assert published[shared] == placed(6) + assert unrelated not in published + + async def test_complete_markdown_capture_keeps_offscreen_table_keylines(): from textual.widgets import Markdown from textual.widgets._markdown import MarkdownTableContent From 06a30381ce2319b79c752f47ee519bf45cc8b317 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 14:57:25 -0400 Subject: [PATCH 9/9] Establish initial focus in inherited text style Tab check --- tests/test_style_inheritance.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_style_inheritance.py b/tests/test_style_inheritance.py index d0b5b09439..1bb9a2ba22 100644 --- a/tests/test_style_inheritance.py +++ b/tests/test_style_inheritance.py @@ -67,17 +67,23 @@ def compose(self) -> ComposeResult: class InheritanceApp(App): def compose(self) -> ComposeResult: - yield Button("button1") + yield Button("button1", id="initial-focus") yield FocusableThing() yield Button("button2") app = InheritanceApp() async with app.run_test() as pilot: + # Headless startup does not promise application-focus admission. + # Establish the starting widget before testing the Tab transition. + initial_focus = app.query_one("#initial-focus", Button) + initial_focus.focus() await pilot.pause() + assert app.focused is initial_focus child = app.query_one("#child-of-focusable-thing") assert child.rich_style.bold assert not child.rich_style.reverse await pilot.press("tab") await pilot.pause() + assert app.focused is app.query_one(FocusableThing) assert child.rich_style.bold assert child.rich_style.reverse