diff --git a/evidence/frame-publication-20261006/README.md b/evidence/frame-publication-20261006/README.md new file mode 100644 index 0000000000..318383d687 --- /dev/null +++ b/evidence/frame-publication-20261006/README.md @@ -0,0 +1,49 @@ +# Independent frame publication + +Native Screen asks its existing preparation owner for deferred Widget roots. +Compositor derives held rectangles from committed geometry, retains their damage, +and publishes other spans through its existing chops and inline formats. +BackgroundScreen borrows the same admission during the synchronous render. +The original callback queue admits senders outside held subtrees; containers of +a held subtree also wait. Screen reports the exact acquired roots only after an +actual non-None display. The application writer remains responsible for flush. + +Mutation roots borrow original committed placements during arrangement. Native +box measurement uses those placements rather than descending into changing +children. The existing scene map remains the geometry owner. No second queue, +timer, damage store or compositor is introduced. + +The shared Toad contract is `_prepare_compositor_refresh() -> tuple[Widget, ...]`, +`_layout_mutation_roots() -> tuple[Widget, ...]`, and +`_on_frame_published(deferred_roots: tuple[Widget, ...])`. Parent owns Toad #482. + +`before.json` uses the existing refactor-audit Package parser over all 249 native +production modules and 461 test modules, with zero omissions. Its 259 lexical +sites identify definitions and references; dynamic subclass and callback +resolution was read semantically, not inferred from absence. + +This remains a draft pending the matching Toad application journey owned by +Parent. Native checks passed: 17 in 1.76s for batch/publication and scene-damage +semantics; then 12 in 1.69s after completing polymorphic sender/backdrop admission, +including inline, translucent, mutation/hit geometry, quiescence and reparenting. +The first focused run refused an ambient-App sidebar callback; the receiver now +owns admission. A later test incorrectly expected a Label at an original cell +owned by its container; the control now compares the actual pre-mutation hit. +Both original negative tool outputs remain in the session. Logs here retain the +final successful checks and the intermediate hit assertion refusal. + +Parent's first matched source App at 225c did not finish and was joined. Its +successful stack observed held-callback idle work; its later empty stack file +is not evidence. The held queue entry called check_idle on every pass. That +wake-up is deleted, and held-only damage no longer resumes the original timer. +No new timer or wakeup mechanism was introduced. New source still requires the +changed matching application check; no latency improvement is claimed. + +Callback scope is polymorphic: MessagePump and Screen await the entire frame; +Widget borrows its own original screen geometry, including backdrops. InvokeLater +is delivered to the receiver's original queue before sender admission. Container +callbacks cover descendants as well as roots enclosing the sender. Mutation +placements also protect hit lookup from a sibling's still-unpainted overlap. +`after.json` parses the staged complete production/test tree with zero omissions; +it records the actual declaration/MRO hooks and lexical consumers. The frozen +textual-native-subtree-strips checkout is unchanged. diff --git a/evidence/frame-publication-20261006/after.json b/evidence/frame-publication-20261006/after.json new file mode 100644 index 0000000000..61c58eed8a --- /dev/null +++ b/evidence/frame-publication-20261006/after.json @@ -0,0 +1,449 @@ +{ + "tree": "e432bd15ad2d28d18200d59e006cd17edb16fa87", + "roots": { + "src/textual": { + "parsed": 249, + "omitted": [] + }, + "tests": { + "parsed": 461, + "omitted": [] + } + }, + "owners": [ + { + "path": "src/textual/_compositor.py", + "line": 392, + "class": "InlineUpdate", + "bases": [ + "CompositorUpdate" + ], + "publication_methods": [] + }, + { + "path": "src/textual/_compositor.py", + "line": 449, + "class": "ChopsUpdate", + "bases": [ + "CompositorUpdate" + ], + "publication_methods": [] + }, + { + "path": "src/textual/_compositor.py", + "line": 544, + "class": "Compositor", + "bases": [], + "publication_methods": [ + "_interaction_deferred", + "deferred_regions", + "_exclude_regions", + "_using_exclusions", + "mutation_box", + "pending_for" + ] + }, + { + "path": "src/textual/message_pump.py", + "line": 122, + "class": "MessagePump", + "bases": [], + "publication_methods": [ + "_after_refresh_pending", + "_on_invoke_later" + ] + }, + { + "path": "src/textual/screen.py", + "line": 144, + "class": "Screen", + "bases": [ + "Generic[ScreenResultType]", + "Widget" + ], + "publication_methods": [ + "_prepare_compositor_refresh", + "_layout_mutation_roots", + "_on_frame_published", + "_prepare_visible_screens", + "_after_refresh_pending", + "_sender_refresh_pending", + "_invoke_later" + ] + }, + { + "path": "src/textual/widget.py", + "line": 303, + "class": "Widget", + "bases": [ + "DOMNode" + ], + "publication_methods": [ + "_after_refresh_pending" + ] + } + ], + "sites": [ + [ + "src/textual/_compositor.py", + 1302, + "declaration", + "_interaction_deferred" + ], + [ + "src/textual/_compositor.py", + 1563, + "declaration", + "deferred_regions" + ], + [ + "src/textual/_compositor.py", + 1592, + "declaration", + "_exclude_regions" + ], + [ + "src/textual/_compositor.py", + 1614, + "declaration", + "_using_exclusions" + ], + [ + "src/textual/_compositor.py", + 1632, + "declaration", + "mutation_box" + ], + [ + "src/textual/_compositor.py", + 1640, + "declaration", + "pending_for" + ], + [ + "src/textual/_compositor.py", + 1319, + "Load", + "self._interaction_deferred" + ], + [ + "src/textual/_compositor.py", + 1642, + "Load", + "widget.screen._compositor.deferred_regions" + ], + [ + "src/textual/_compositor.py", + 1736, + "Load", + "self._exclude_regions" + ], + [ + "src/textual/_compositor.py", + 1745, + "Load", + "self._exclude_regions" + ], + [ + "src/textual/_compositor.py", + 1760, + "Load", + "self._exclude_regions" + ], + [ + "src/textual/_compositor.py", + 872, + "Load", + "root.screen._layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1306, + "Load", + "self.deferred_regions" + ], + [ + "src/textual/_compositor.py", + 1361, + "Load", + "self._interaction_deferred" + ], + [ + "src/textual/_compositor.py", + 1306, + "Load", + "self.root.screen._layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1289, + "Load", + "self.root.screen._layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1290, + "Load", + "self.deferred_regions" + ], + [ + "src/textual/message_pump.py", + 486, + "declaration", + "_after_refresh_pending" + ], + [ + "src/textual/message_pump.py", + 562, + "declaration", + "_on_invoke_later" + ], + [ + "src/textual/message_pump.py", + 567, + "Load", + "self.app.screen._invoke_later" + ], + [ + "src/textual/screen.py", + 1205, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "src/textual/screen.py", + 1209, + "declaration", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1213, + "declaration", + "_on_frame_published" + ], + [ + "src/textual/screen.py", + 1216, + "declaration", + "_prepare_visible_screens" + ], + [ + "src/textual/screen.py", + 1221, + "declaration", + "_after_refresh_pending" + ], + [ + "src/textual/screen.py", + 1319, + "declaration", + "_sender_refresh_pending" + ], + [ + "src/textual/screen.py", + 1353, + "declaration", + "_invoke_later" + ], + [ + "src/textual/screen.py", + 1223, + "Load", + "MessagePump._after_refresh_pending" + ], + [ + "src/textual/screen.py", + 1233, + "Load", + "self._prepare_visible_screens" + ], + [ + "src/textual/screen.py", + 1334, + "Load", + "screen._compositor.pending_for" + ], + [ + "src/textual/screen.py", + 1275, + "Load", + "self._compositor._exclude_regions" + ], + [ + "src/textual/screen.py", + 1346, + "Load", + "sender._after_refresh_pending" + ], + [ + "src/textual/screen.py", + 1451, + "Load", + "self._layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1196, + "Load", + "self._compositor._exclude_regions" + ], + [ + "src/textual/screen.py", + 1219, + "Load", + "screen._prepare_compositor_refresh" + ], + [ + "src/textual/screen.py", + 1237, + "Load", + "screen._compositor.deferred_regions" + ], + [ + "src/textual/screen.py", + 1265, + "Load", + "self._on_frame_published" + ], + [ + "src/textual/screen.py", + 1266, + "Load", + "self._compositor._interaction_deferred" + ], + [ + "src/textual/screen.py", + 1281, + "Load", + "self._compositor._exclude_regions" + ], + [ + "src/textual/screen.py", + 1285, + "Load", + "self._compositor._interaction_deferred" + ], + [ + "src/textual/screen.py", + 1244, + "Load", + "screen._compositor._using_exclusions" + ], + [ + "src/textual/screen.py", + 1193, + "Load", + "self._prepare_visible_screens" + ], + [ + "src/textual/screen.py", + 1345, + "Load", + "self._prepare_visible_screens" + ], + [ + "src/textual/screen.py", + 1199, + "Load", + "root.screen._compositor.deferred_regions" + ], + [ + "src/textual/widget.py", + 1405, + "declaration", + "_after_refresh_pending" + ], + [ + "src/textual/widget.py", + 1407, + "Load", + "screen._sender_refresh_pending" + ], + [ + "src/textual/widget.py", + 1934, + "Load", + "self.screen._compositor.mutation_box" + ], + [ + "tests/test_batch_paint_admission.py", + 17, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 148, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 151, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 154, + "declaration", + "_on_frame_published" + ], + [ + "tests/test_batch_paint_admission.py", + 103, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 19, + "Load", + "super()._prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 152, + "Load", + "self._layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 269, + "Load", + "source._compositor.deferred_regions" + ], + [ + "tests/test_batch_paint_admission.py", + 105, + "Load", + "super()._prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 276, + "Load", + "source._compositor._using_exclusions" + ], + [ + "tests/test_batch_paint_admission.py", + 207, + "Load", + "compositor.deferred_regions" + ], + [ + "tests/test_batch_paint_admission.py", + 242, + "Load", + "compositor.deferred_regions" + ], + [ + "tests/test_reparent.py", + 125, + "Load", + "previous._invoke_later" + ] + ], + "dynamic_resolution": "Public subclass hooks can be overridden outside these roots. Callback targets remain dynamic; original receiver/polymorphic publication ownership was read semantically." +} diff --git a/evidence/frame-publication-20261006/before.json b/evidence/frame-publication-20261006/before.json new file mode 100644 index 0000000000..6e01403ad0 --- /dev/null +++ b/evidence/frame-publication-20261006/before.json @@ -0,0 +1,1570 @@ +{ + "head": "a2dd8a9a37825a828df0d8f651ca57d92db69e5f", + "roots": { + "src/textual": { + "parsed": 249, + "omitted": [] + }, + "tests": { + "parsed": 461, + "omitted": [] + } + }, + "sites": [ + [ + "src/textual/_arrange.py", + 39, + "declaration", + "arrange" + ], + [ + "src/textual/_arrange.py", + 155, + "Load", + "dock_widget._get_box_model" + ], + [ + "src/textual/_arrange.py", + 228, + "Load", + "split_widget._get_box_model" + ], + [ + "src/textual/_arrange.py", + 97, + "Load", + "widget.layout.arrange" + ], + [ + "src/textual/_compositor.py", + 672, + "declaration", + "reflow" + ], + [ + "src/textual/_compositor.py", + 726, + "declaration", + "reflow_visible" + ], + [ + "src/textual/_compositor.py", + 837, + "declaration", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 1500, + "declaration", + "render_update" + ], + [ + "src/textual/_compositor.py", + 1524, + "declaration", + "render_inline" + ], + [ + "src/textual/_compositor.py", + 1544, + "declaration", + "render_full_update" + ], + [ + "src/textual/_compositor.py", + 1571, + "declaration", + "render_partial_update" + ], + [ + "src/textual/_compositor.py", + 1592, + "declaration", + "render_strips" + ], + [ + "src/textual/_compositor.py", + 700, + "Load", + "self._arrange_root" + ], + [ + "src/textual/_compositor.py", + 749, + "Load", + "self._arrange_root" + ], + [ + "src/textual/_compositor.py", + 1541, + "Load", + "self.render_strips" + ], + [ + "src/textual/_compositor.py", + 1638, + "Load", + "self._arrange_root" + ], + [ + "src/textual/_compositor.py", + 798, + "Load", + "self._arrange_root" + ], + [ + "src/textual/_compositor.py", + 1520, + "Load", + "self.render_full_update" + ], + [ + "src/textual/_compositor.py", + 1522, + "Load", + "self.render_partial_update" + ], + [ + "src/textual/_compositor.py", + 1731, + "Load", + "self.render_strips" + ], + [ + "src/textual/_compositor.py", + 953, + "Load", + "widget.arrange" + ], + [ + "src/textual/_measurement.py", + 116, + "Load", + "type(widget).arrange" + ], + [ + "src/textual/_measurement.py", + 116, + "Load", + "Widget.arrange" + ], + [ + "src/textual/_resolve.py", + 254, + "Load", + "widget._get_box_model" + ], + [ + "src/textual/_resolve.py", + 341, + "Load", + "widget._get_box_model" + ], + [ + "src/textual/app.py", + 3863, + "declaration", + "_display" + ], + [ + "src/textual/app.py", + 1893, + "Load", + "self.screen._compositor.render_update" + ], + [ + "src/textual/app.py", + 3851, + "Load", + "self.screen._refresh_layout" + ], + [ + "src/textual/content.py", + 717, + "declaration", + "render_strips" + ], + [ + "src/textual/css/_style_properties.py", + 843, + "Store", + "self._display" + ], + [ + "src/textual/css/_style_properties.py", + 867, + "Load", + "self._display" + ], + [ + "src/textual/layout.py", + 262, + "declaration", + "arrange" + ], + [ + "src/textual/layout.py", + 247, + "Load", + "cls.arrange" + ], + [ + "src/textual/layout.py", + 294, + "Load", + "widget.arrange" + ], + [ + "src/textual/layout.py", + 321, + "Load", + "widget.arrange" + ], + [ + "src/textual/layout.py", + 323, + "Load", + "widget.arrange" + ], + [ + "src/textual/layouts/grid.py", + 50, + "declaration", + "arrange" + ], + [ + "src/textual/layouts/grid.py", + 334, + "Load", + "widget._get_box_model" + ], + [ + "src/textual/layouts/horizontal.py", + 24, + "declaration", + "arrange" + ], + [ + "src/textual/layouts/stream.py", + 45, + "declaration", + "arrange" + ], + [ + "src/textual/layouts/stream.py", + 153, + "Load", + "widget.arrange" + ], + [ + "src/textual/layouts/vertical.py", + 22, + "declaration", + "arrange" + ], + [ + "src/textual/message_pump.py", + 557, + "Load", + "self.app.screen._invoke_later" + ], + [ + "src/textual/pilot.py", + 547, + "Load", + "self.app.screen._on_timer_update" + ], + [ + "src/textual/pilot.py", + 552, + "Load", + "self.app.screen._on_timer_update" + ], + [ + "src/textual/pilot.py", + 560, + "Load", + "self.app.screen._on_timer_update" + ], + [ + "src/textual/renderables/blank.py", + 27, + "declaration", + "render_strips" + ], + [ + "src/textual/screen.py", + 513, + "declaration", + "arrange" + ], + [ + "src/textual/screen.py", + 1176, + "declaration", + "_refresh_pending" + ], + [ + "src/textual/screen.py", + 1197, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "src/textual/screen.py", + 1207, + "declaration", + "_compositor_refresh" + ], + [ + "src/textual/screen.py", + 1259, + "declaration", + "_on_timer_update" + ], + [ + "src/textual/screen.py", + 1290, + "declaration", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1305, + "declaration", + "_invoke_later" + ], + [ + "src/textual/screen.py", + 1357, + "declaration", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1191, + "Load", + "self._refresh_pending" + ], + [ + "src/textual/screen.py", + 354, + "Load", + "self._on_timer_update" + ], + [ + "src/textual/screen.py", + 1195, + "Load", + "self._invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1288, + "Load", + "self._invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1296, + "Load", + "self._refresh_pending" + ], + [ + "src/textual/screen.py", + 1434, + "Load", + "self._compositor_refresh" + ], + [ + "src/textual/screen.py", + 1541, + "Load", + "self._refresh_layout" + ], + [ + "src/textual/screen.py", + 1232, + "Load", + "app._display" + ], + [ + "src/textual/screen.py", + 1243, + "Load", + "self._compositor.render_update" + ], + [ + "src/textual/screen.py", + 1246, + "Load", + "app._display" + ], + [ + "src/textual/screen.py", + 1272, + "Load", + "self._refresh_layout" + ], + [ + "src/textual/screen.py", + 1281, + "Load", + "self._compositor_refresh" + ], + [ + "src/textual/screen.py", + 1371, + "Load", + "self._compositor.reflow_visible" + ], + [ + "src/textual/screen.py", + 1560, + "Load", + "self._refresh_layout" + ], + [ + "src/textual/screen.py", + 1222, + "Load", + "screen._prepare_compositor_refresh" + ], + [ + "src/textual/screen.py", + 1234, + "Load", + "self._compositor.render_inline" + ], + [ + "src/textual/screen.py", + 1275, + "Load", + "self._refresh_layout" + ], + [ + "src/textual/screen.py", + 1398, + "Load", + "self._compositor.reflow" + ], + [ + "src/textual/screen.py", + 1402, + "Load", + "self._compositor.reflow" + ], + [ + "src/textual/visual.py", + 131, + "declaration", + "render_strips" + ], + [ + "src/textual/visual.py", + 324, + "declaration", + "render_strips" + ], + [ + "src/textual/visual.py", + 391, + "declaration", + "render_strips" + ], + [ + "src/textual/visual.py", + 227, + "Load", + "visual.render_strips" + ], + [ + "src/textual/visual.py", + 411, + "Load", + "self._visual.render_strips" + ], + [ + "src/textual/widget.py", + 1405, + "declaration", + "arrange" + ], + [ + "src/textual/widget.py", + 1909, + "declaration", + "_get_box_model" + ], + [ + "src/textual/widget.py", + 4106, + "Load", + "cls._get_box_model" + ], + [ + "src/textual/widget.py", + 4106, + "Load", + "Widget._get_box_model" + ], + [ + "src/textual/widget.py", + 4114, + "Load", + "cls.arrange" + ], + [ + "src/textual/widget.py", + 4114, + "Load", + "Widget.arrange" + ], + [ + "tests/frame_damage_installed_pilot.py", + 15, + "Load", + "compositor.reflow" + ], + [ + "tests/frame_damage_installed_pilot.py", + 16, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_batch_paint_admission.py", + 16, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 33, + "declaration", + "_display" + ], + [ + "tests/test_batch_paint_admission.py", + 102, + "declaration", + "_prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 18, + "Load", + "super()._prepare_compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 36, + "Load", + "super()._display" + ], + [ + "tests/test_batch_paint_admission.py", + 126, + "Load", + "foreground._compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 50, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 59, + "Load", + "screen._compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 122, + "Load", + "foreground._compositor_refresh" + ], + [ + "tests/test_batch_paint_admission.py", + 104, + "Load", + "super()._prepare_compositor_refresh" + ], + [ + "tests/test_box_model.py", + 30, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 42, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 61, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 67, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 73, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 80, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 86, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 92, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 111, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 117, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 123, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 129, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 135, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 142, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 165, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model.py", + 188, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model_cache_lifetime.py", + 67, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_box_model_cache_lifetime.py", + 69, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_box_model_cache_lifetime.py", + 78, + "Load", + "parent.arrange" + ], + [ + "tests/test_box_model_cache_lifetime.py", + 19, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_box_model_cache_lifetime.py", + 73, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_compositor.py", + 157, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_compositor.py", + 161, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor.py", + 190, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_compositor.py", + 199, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor.py", + 388, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor.py", + 195, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor.py", + 251, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor.py", + 308, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor.py", + 349, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor.py", + 392, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor.py", + 106, + "Load", + "app.screen._compositor._arrange_root" + ], + [ + "tests/test_compositor.py", + 162, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor.py", + 194, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor.py", + 313, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor.py", + 357, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor_frame_damage.py", + 16, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor_frame_damage.py", + 17, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor_frame_damage.py", + 58, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor_frame_damage.py", + 103, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor_frame_damage.py", + 139, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor_frame_damage.py", + 144, + "Load", + "compositor.render_partial_update" + ], + [ + "tests/test_compositor_frame_damage.py", + 192, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 222, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor_frame_damage.py", + 231, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor_frame_damage.py", + 238, + "Load", + "compositor.reflow" + ], + [ + "tests/test_compositor_frame_damage.py", + 275, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_compositor_frame_damage.py", + 279, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 296, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_compositor_horizontal_damage.py", + 46, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_compositor_horizontal_damage.py", + 52, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_content.py", + 39, + "Load", + "content.render_strips" + ], + [ + "tests/test_content.py", + 40, + "Load", + "content.render_strips" + ], + [ + "tests/test_content.py", + 337, + "Load", + "content.render_strips" + ], + [ + "tests/test_frame_transaction.py", + 16, + "Load", + "screen._on_timer_update" + ], + [ + "tests/test_frame_transaction.py", + 15, + "Load", + "screen._compositor_refresh" + ], + [ + "tests/test_frame_transaction.py", + 19, + "Load", + "screen._compositor.render_strips" + ], + [ + "tests/test_height_independent_arrangement.py", + 269, + "declaration", + "arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 29, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 30, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 47, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 57, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 74, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 75, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 90, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 92, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 108, + "Load", + "leaf._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 111, + "Load", + "leaf._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 131, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 166, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 198, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 202, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 227, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 246, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 263, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 334, + "Load", + "child._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 337, + "Load", + "child._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 37, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 39, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 41, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 46, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 52, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 64, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 78, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 79, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 132, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 150, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 151, + "Load", + "sibling.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 167, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 168, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 169, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 170, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 200, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 204, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 228, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 247, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 264, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 270, + "Load", + "super().arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 292, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 311, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 312, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 153, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 154, + "Load", + "sibling.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 293, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 316, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 317, + "Load", + "parent._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 374, + "Load", + "child._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 378, + "Load", + "child._get_box_model" + ], + [ + "tests/test_height_independent_arrangement.py", + 195, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 193, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 396, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_arrangement.py", + 398, + "Load", + "parent.arrange" + ], + [ + "tests/test_height_independent_measurement.py", + 103, + "declaration", + "arrange" + ], + [ + "tests/test_height_independent_measurement.py", + 164, + "declaration", + "arrange" + ], + [ + "tests/test_height_independent_measurement.py", + 19, + "Load", + "widget._get_box_model" + ], + [ + "tests/test_height_independent_measurement.py", + 104, + "Load", + "super().arrange" + ], + [ + "tests/test_height_independent_measurement.py", + 165, + "Load", + "super().arrange" + ], + [ + "tests/test_native_scene_size_publication.py", + 108, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_native_scene_size_publication.py", + 124, + "Load", + "compositor.reflow" + ], + [ + "tests/test_native_scene_size_publication.py", + 141, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_native_scene_size_publication.py", + 145, + "Load", + "compositor.reflow" + ], + [ + "tests/test_removed_scene_ownership.py", + 112, + "Load", + "parent.arrange" + ], + [ + "tests/test_removed_scene_ownership.py", + 146, + "Load", + "app.screen._compositor.render_strips" + ], + [ + "tests/test_removed_scene_ownership.py", + 168, + "Load", + "owner._compositor.render_strips" + ], + [ + "tests/test_removed_scene_ownership.py", + 197, + "Load", + "owner._compositor.render_strips" + ], + [ + "tests/test_removed_scene_ownership.py", + 198, + "Load", + "parent.arrange" + ], + [ + "tests/test_removed_scene_ownership.py", + 228, + "Load", + "owner._compositor.render_strips" + ], + [ + "tests/test_removed_scene_ownership.py", + 178, + "Load", + "owner._compositor.render_strips" + ], + [ + "tests/test_removed_scene_ownership.py", + 205, + "Load", + "parent.arrange" + ], + [ + "tests/test_removed_scene_ownership.py", + 209, + "Load", + "owner._compositor.render_strips" + ], + [ + "tests/test_reparent.py", + 125, + "Load", + "previous._invoke_later" + ], + [ + "tests/test_structural_measurement_revision.py", + 23, + "Load", + "outer._get_box_model" + ], + [ + "tests/test_structural_measurement_revision.py", + 52, + "Load", + "outer._get_box_model" + ], + [ + "tests/test_subtree_geometry_cache.py", + 26, + "Load", + "Compositor(max_subtree_geometry_entries=0)._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 40, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 49, + "Load", + "Compositor(max_subtree_geometry_entries=0)._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 24, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 42, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 47, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 41, + "Load", + "group.arrange" + ], + [ + "tests/test_subtree_geometry_cache.py", + 46, + "Load", + "group.arrange" + ], + [ + "tests/test_viewport_geometry_targets.py", + 227, + "Load", + "compositor._arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 134, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 184, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 185, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 198, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 220, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 256, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 61, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 65, + "Load", + "Compositor(max_subtree_geometry_entries=0)._arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 107, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 167, + "Load", + "compositor.reflow" + ], + [ + "tests/test_viewport_geometry_targets.py", + 159, + "Load", + "screen._refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 158, + "Load", + "compositor.reflow_visible" + ], + [ + "tests/test_viewport_geometry_targets.py", + 166, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_viewport_geometry_targets.py", + 171, + "Load", + "compositor.render_strips" + ], + [ + "tests/test_viewport_layout.py", + 56, + "Load", + "screen._compositor.reflow" + ], + [ + "tests/test_viewport_layout.py", + 61, + "Load", + "screen._compositor.reflow" + ], + [ + "tests/test_viewport_layout.py", + 40, + "Load", + "screen._compositor.reflow" + ], + [ + "tests/test_viewport_layout.py", + 22, + "Load", + "screen._compositor.render_strips" + ], + [ + "tests/toad_frame_damage_installed_pilot.py", + 33, + "Load", + "self.screen._compositor.render_strips" + ] + ], + "resolution": "Lexical definitions and references; dynamic subclasses and callback targets require semantic reads." +} diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 2c15127332..2f3f2ce329 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -18,6 +18,7 @@ from collections import Counter from contextlib import contextmanager from functools import cached_property +from fractions import Fraction from types import MappingProxyType from typing import ( TYPE_CHECKING, @@ -39,6 +40,7 @@ from rich.style import Style from textual import errors +from textual.box_model import BoxModel from textual._context import visible_screen_stack from textual._loop import loop_last from textual._spatial_map import SpatialMap @@ -394,6 +396,18 @@ def __init__(self, strips: list[Strip], clear: bool = False) -> None: self.strips = strips self.clear = clear + @classmethod + def from_chops(cls, update: ChopsUpdate, height: int) -> InlineUpdate: + """Use inline-relative cursor movement for the original admitted cells.""" + rows: list[list[Segment]] = [[] for _ in range(height)] + cursors = [0] * height + for y, x1, x2 in update.spans: + for x, strip in update._get_line_chops(y, x1, x2): + rows[y].append(Control.move(x - cursors[y]).segment) + rows[y].extend(strip) + cursors[y] = x + strip.cell_length + return cls([Strip(row) for row in rows]) + def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: @@ -539,6 +553,10 @@ def __init__(self, *, max_subtree_geometry_entries: int = DEFAULT_SUBTREE_GEOMET self._full_map_invalidated = True self._arranging = False """Geometry reads during measurement observe the last committed map.""" + self._layout_geometry: Mapping[Widget, MapGeometry | None] = {} + """Borrowed mutation placements, only during synchronous arrangement.""" + self._render_exclusions: tuple[Region, ...] = () + """The acquired frame bounds, borrowed by translucent backdrop rendering.""" self._render_geometry: tuple[Widget, CompositorMap] | None = None """The original arrangement selected by a synchronous body capture.""" self._visible_map: CompositorMap | None = None @@ -689,7 +707,6 @@ def reflow( previous_scene = self._published_map previous_map = previous_scene if visible_only else self._full_map self._invalidate_render_projection() - self._visible_map = None self.root = parent self.size = size @@ -714,6 +731,7 @@ def reflow( self._visible_map = map else: self._full_map = map + self._visible_map = None self._full_map_invalidated = visible_only # Measuring widgets may inspect geometry and populate presentation # caches from the previous committed map. Publish only the new map. @@ -851,6 +869,17 @@ def _arrange_root( Compositor map and set of widgets. """ + mutation_roots = set(root.screen._layout_mutation_roots()) + previous = self._published_map + held: dict[Widget, CompositorMap] = {owner: {} for owner in mutation_roots} + mutation_paths: set[Widget] = set() + for owner in mutation_roots: + mutation_paths.update(owner.walk_ancestors(with_self=True)) + 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: + self._subtree_geometry.pop(owner, None) map: CompositorMap = {} # Transient declaration paths manufacture the one retained resource. # The published MapGeometry continues to own the actual clipped scene. @@ -1108,10 +1137,17 @@ 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 (not self.max_subtree_geometry_entries or not widget.CACHE_SUBTREE_GEOMETRY - or not widget._is_mounted): + 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, complete, inherited_layers) # noqa: F821 -- closure cleared after traversal return @@ -1153,6 +1189,11 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible retained=retained_paths, bounds=root_geometry.region) # Add top level (root) widget + previous_layout_geometry = self._layout_geometry + self._layout_geometry = { + owner: previous.get(owner._render_widget) for owner in mutation_roots + } + previous_arranging = self._arranging self._arranging = True try: add_widget( @@ -1168,12 +1209,16 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible root_layer_order, ) finally: - self._arranging = False + self._arranging = previous_arranging + self._layout_geometry = previous_layout_geometry # Both recursive closures otherwise retain themselves through # their closure cells, keeping old maps and entire widget trees # alive until cyclic GC. Reflow is finished, so break those local # recursion links before returning the authoritative scene map. del add_widget, arrange_widget + for geometry in held.values(): + map.update(geometry) + widgets.update(geometry) widgets -= invisible_widgets return map, widgets @@ -1240,11 +1285,27 @@ def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]: Sequence of (WIDGET, REGION) tuples. """ contains = Region.contains + held = () if self.root is None else tuple( + root for root in self.root.screen._layout_mutation_roots() + if any(region.contains(x, y) for region in self.deferred_regions((root,))) + ) if self.size.height > y >= 0: for widget, (region, clip) in self.visible_widgets.items(): if contains(region, x, y) and contains(clip, x, y) and widget.visible: + if held and not ( + set(widget.walk_ancestors(with_self=True)).intersection(held) + or any(widget in root.walk_ancestors(with_self=True) for root in held) + ): + continue yield widget, region + def _interaction_deferred(self, x: int, y: int) -> bool: + """A geometry query cannot render held source to acquire metadata.""" + regions = self._render_exclusions + if self.root is not None: + regions += self.deferred_regions(self.root.screen._layout_mutation_roots()) + return any(region.contains(x, y) for region in regions) + def get_style_at(self, x: int, y: int) -> Style: """Get the Style at the given cell or Style.null() @@ -1255,6 +1316,8 @@ def get_style_at(self, x: int, y: int) -> Style: Returns: The Style at the cell (x, y) within the Layout. """ + if self._interaction_deferred(x, y): + return Style.null() try: widget, region = self.get_widget_at(x, y) except errors.NoWidget: @@ -1295,8 +1358,8 @@ def get_widget_and_offset_at( widget, region = self.get_widget_at(x, y) except errors.NoWidget: return None, None - if widget not in self.visible_widgets: - return None, None + if widget not in self.visible_widgets or self._interaction_deferred(x, y): + return widget, None if y >= widget.content_region.bottom: x, y = widget.content_region.bottom_right_inclusive @@ -1497,11 +1560,94 @@ def _get_renders( ), ) + def deferred_regions(self, roots: Iterable[Widget]) -> tuple[Region, ...]: + """Derive held paint bounds from committed geometry, never lazy layout. + + A mounted root without a placement holds its nearest placed ancestor: + an unknown destination cannot authorize painting through that subtree. + Overflowing descendants retain their own original clipped bounds. + """ + roots = set(roots) + regions: set[Region] = set() + placed: set[Widget] = set() + for widget, geometry in self._published_map.items(): + members = roots.intersection(widget.walk_ancestors(with_self=True)) + if members: + placed.update(members) + if region := geometry.visible_region.intersection(self.size.region): + regions.add(region) + for root in roots - placed: + if not root.is_attached: + continue + for ancestor in root.walk_ancestors(): + if (geometry := self._published_map.get(ancestor)) is not None: + if region := geometry.visible_region.intersection(self.size.region): + regions.add(region) + break + else: + regions.add(self.size.region) + return tuple(regions) + + @staticmethod + def _exclude_regions(regions: Iterable[Region], exclusions: Iterable[Region]) -> set[Region]: + """Partition original positive rectangles without changing cell coordinates.""" + result = set(regions) + for exclusion in exclusions: + remaining: set[Region] = set() + for region in result: + overlap = region.intersection(exclusion) + if not overlap: + remaining.add(region) + continue + for part in ( + Region(region.x, region.y, region.width, overlap.y - region.y), + Region(region.x, overlap.bottom, region.width, region.bottom - overlap.bottom), + Region(region.x, overlap.y, overlap.x - region.x, overlap.height), + Region(overlap.right, overlap.y, region.right - overlap.right, overlap.height), + ): + if part: + remaining.add(part) + result = remaining + return result + + @contextmanager + def _using_exclusions(self, regions: tuple[Region, ...]) -> Iterator[None]: + """Borrow the same frame admission for nested BackgroundScreen renders.""" + previous = self._render_exclusions + self._render_exclusions = regions + try: + yield + finally: + self._render_exclusions = previous + + def _publication_cuts(self, exclusions: Iterable[Region]) -> list[list[int]]: + """Held boundaries split cut cells before damage expands to native strips.""" + cuts = [line.copy() for line in self.cuts] + for region in exclusions: + region = region.intersection(self.size.region) + for row in range(region.y, region.bottom): + cuts[row] = sorted(set((*cuts[row], region.x, region.right))) + return cuts + + def mutation_box(self, widget: Widget) -> BoxModel | None: + """Borrow the committed root box; an unplaced mutation has no size yet.""" + if widget not in self._layout_geometry: + return None + geometry = self._layout_geometry[widget] + size = Size() if geometry is None else geometry.region.size + return BoxModel(Fraction(size.width), Fraction(size.height), widget.styles.margin) + + def pending_for(self, widget: Widget) -> bool: + """Whether this sender still owns damage in the original committed scene.""" + regions = widget.screen._compositor.deferred_regions((widget,)) + return any(damage.overlaps(region) for damage in self._dirty_regions for region in regions) + def render_update( self, full: bool = False, screen_stack: list[Screen] | None = None, simplify: bool = False, + excluded_regions: tuple[Region, ...] = (), ) -> RenderableType | None: """Render an update renderable. @@ -1516,17 +1662,21 @@ def render_update( visible_screen_stack.set([] if screen_stack is None else screen_stack) screen_region = self.size.region + if full: + self._dirty_regions.add(screen_region) + if excluded_regions: + return self.render_partial_update(excluded_regions=excluded_regions) if full or screen_region in self._dirty_regions: return self.render_full_update(simplify=simplify) - else: - return self.render_partial_update() + return self.render_partial_update() def render_inline( self, size: Size, screen_stack: list[Screen] | None = None, clear: bool = False, - ) -> RenderableType: + excluded_regions: tuple[Region, ...] = (), + ) -> RenderableType | None: """Render an inline update. Args: @@ -1538,7 +1688,12 @@ def render_inline( A renderable. """ visible_screen_stack.set([] if screen_stack is None else screen_stack) + if excluded_regions: + self._dirty_regions.add(self.size.region) + update = self.render_partial_update(excluded_regions=excluded_regions) + return None if update is None else InlineUpdate.from_chops(update, size.height) strips = self.render_strips(size) + self._dirty_regions.clear() return InlineUpdate(strips, clear=clear) def render_full_update(self, simplify: bool = False) -> LayoutUpdate: @@ -1551,7 +1706,6 @@ def render_full_update(self, simplify: bool = False) -> LayoutUpdate: A LayoutUpdate renderable. """ screen_region = self.size.region - self._dirty_regions.clear() crop = screen_region chops = self._render_chops(crop, self._regions_to_spans((screen_region,)), widgets=self.visible_widgets, cuts=self.cuts, @@ -1566,9 +1720,10 @@ def render_full_update(self, simplify: bool = False) -> LayoutUpdate: else: render_strips = [chop.values() for chop in chops] + self._dirty_regions.clear() return LayoutUpdate(render_strips, screen_region) - def render_partial_update(self) -> ChopsUpdate | None: + def render_partial_update(self, *, excluded_regions: tuple[Region, ...] = ()) -> ChopsUpdate | None: """Render a partial update. Returns: @@ -1578,15 +1733,16 @@ def render_partial_update(self) -> ChopsUpdate | None: update_regions = { damage for region in self._dirty_regions if (damage := region.intersection(screen_region)) } - self._dirty_regions.clear() - if not update_regions: + ready_regions = self._exclude_regions(update_regions, excluded_regions) + if not ready_regions: return None - crop = Region.from_union(update_regions) - spans = list(self._regions_to_spans(update_regions)) - cuts = self.cuts + crop = Region.from_union(ready_regions) + spans = list(self._regions_to_spans(ready_regions)) + cuts = self._publication_cuts(excluded_regions) if excluded_regions else self.cuts chops = self._render_chops(crop, spans, widgets=self.visible_widgets, cuts=cuts, bounds=screen_region) + self._dirty_regions = self._exclude_regions(update_regions, ready_regions) return ChopsUpdate(chops, spans, cuts) def render_strips(self, size: Size | None = None) -> list[Strip]: @@ -1601,10 +1757,15 @@ def render_strips(self, size: Size | None = None) -> list[Strip]: if size is None: size = self.size rows = Region(0, 0, self.size.width, min(size.height, self.size.height)) - chops = self._render_chops(size.region, self._regions_to_spans((rows,)), - widgets=self.visible_widgets, cuts=self.cuts, + admitted = self._exclude_regions((rows,), self._render_exclusions) + cuts = self._publication_cuts(self._render_exclusions) if self._render_exclusions else self.cuts + chops = self._render_chops(size.region, self._regions_to_spans(admitted), + widgets=self.visible_widgets, cuts=cuts, bounds=self.size.region) - render_strips = [Strip.join(chop.values()) for chop in chops[: size.height]] + render_strips = [Strip.join( + chop.get(x) or Strip.blank(end - x) + for x, end in zip(cuts[y], cuts[y][1:]) + ) for y, chop in enumerate(chops[: size.height])] return render_strips def published_geometry( diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index c8c5c5fcd6..05a0c55818 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -52,6 +52,8 @@ from textual.app import App from textual.css.model import SelectorSet from textual.dom import DOMNode + from textual.screen import Screen + from textual.widget import Widget Callback: TypeAlias = "Callable[..., Any] | Callable[..., Awaitable[Any]]" @@ -481,9 +483,15 @@ def set_interval( self._timers.add(timer) return timer + def _after_refresh_pending(self, screen: Screen, roots: tuple[Widget, ...]) -> bool: + """Non-spatial owners await the complete admitted screen publication.""" + return screen._refresh_pending or bool(roots) + def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> bool: - """Schedule a callback to run after all messages are processed and the screen - has been refreshed. Positional and keyword arguments are passed to the callable. + """Schedule a callback after this owner's messages and publication complete. + + Widget callbacks await their subtree; Screen and App callbacks await the + complete frame. Positional and keyword arguments are passed to the callable. Args: callback: A callable. @@ -554,9 +562,9 @@ def call_next(self, callback: Callback, *args: Any, **kwargs: Any) -> None: def _on_invoke_later(self, message: messages.InvokeLater) -> None: # Forward InvokeLater message to the Screen if self.app._running: - self.app.screen._invoke_later( - message.callback, message._sender or active_message_pump.get() - ) + # call_after_refresh posts to this owner's queue first. That owner, + # rather than the ambient caller, owns the publication being awaited. + self.app.screen._invoke_later(message.callback, self) async def _close_messages(self, wait: bool = True) -> None: """Close message queue, and optionally wait for queue to finish processing.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 44a8d57604..dde0c10e19 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +from contextlib import ExitStack from functools import partial from operator import attrgetter from typing import ( @@ -55,7 +56,7 @@ from textual.geometry import Offset, Region, Size from textual.keys import key_to_character from textual.layout import DockArrangeResult -from textual.message_pump import MessagePumpClosed +from textual.message_pump import MessagePump, MessagePumpClosed from textual.reactive import Reactive, var from textual.renderables.background_screen import BackgroundScreen from textual.renderables.blank import Blank @@ -1189,72 +1190,100 @@ async def _on_idle(self, event: events.Idle) -> None: event.prevent_default() if not self.app._batch_count and self.is_current: if self._refresh_pending: - self._update_timer.resume() - return + roots = tuple(root for _, roots in self._prepare_visible_screens() for root in roots) + if (self._layout_required or self._scroll_required or self._repaint_required + or self._recompose_required or self._dirty_widgets + or self._compositor._exclude_regions( + self._compositor._dirty_regions, + tuple(region for root in roots + for region in root.screen._compositor.deferred_regions((root,))), + )): + self._update_timer.resume() await self._invoke_and_clear_callbacks() - def _prepare_compositor_refresh(self) -> bool: - """Prepare an admitted scene; return False while it cannot be painted. + def _prepare_compositor_refresh(self) -> tuple[Widget, ...]: + """Prepare the frame and return original subtrees whose paint is held.""" + return () - Terminal refresh calls this hook for every screen it will composite, - after batch admission and before consuming any compositor damage. - Deferred preparation retains the existing repaint intent; the scene - owner arranges its next refresh through the update timer. - """ - return True + def _layout_mutation_roots(self) -> tuple[Widget, ...]: + """Subtrees borrowing their committed geometry during source mutation.""" + return () - def _compositor_refresh(self) -> None: - """Perform a compositor refresh.""" + def _on_frame_published(self, deferred_roots: tuple[Widget, ...]) -> None: + """Observe an actual displayed update with its acquired admission roots.""" + + def _prepare_visible_screens(self) -> tuple[tuple[Screen, tuple[Widget, ...]], ...]: + """Acquire the same original visible stack for paint and sender admission.""" + screens = (*self.app._background_screens, self) if self is self.app.screen else (self,) + return tuple((screen, screen._prepare_compositor_refresh()) for screen in screens) + def _after_refresh_pending(self, screen, roots: tuple[Widget, ...]) -> bool: + """Screen-owned work retains the original whole-frame admission.""" + return MessagePump._after_refresh_pending(self, screen, roots) + + def _compositor_refresh(self) -> None: + """Publish admitted damage, retaining held subtree damage in Compositor.""" app = self.app if app._batch_count: return - # A translucent foreground renders its backdrops through BackgroundScreen, - # without entering those screens' refresh methods. Admit that same visible - # stack before rendering any rows, including an invalidated backdrop. background_screens = app._background_screens - screens = (*background_screens, self) if self is app.screen else (self,) if self is app.screen and app.is_inline: - # Height acquisition can invalidate a body's captured width. - # Complete that geometry read before asking its scene to paint. inline_height = app._get_inline_height() - if not all(screen._prepare_compositor_refresh() for screen in screens): - self._repaint_required = True - return - + cohort = self._prepare_visible_screens() + deferred_roots = tuple(root for _, roots in cohort for root in roots) + excluded_regions = tuple( + region for screen, roots in cohort + for region in screen._compositor.deferred_regions(roots) + ) if self is app.screen: - if app.is_inline: - clear = ( - app._previous_inline_height is not None - and inline_height < app._previous_inline_height - ) - app._display( - self, - self._compositor.render_inline( - app.size.with_height(inline_height), + # BackgroundScreen can render its compositor inside the foreground's + # renderer. Borrow this exact admission, including every backdrop. + with ExitStack() as frame: + for screen, _ in cohort: + frame.enter_context(screen._compositor._using_exclusions(excluded_regions)) + if app.is_inline: + clear = ( + app._previous_inline_height is not None + and inline_height < app._previous_inline_height + ) + update = self._compositor.render_inline( + app.size.with_height(max(inline_height, app._previous_inline_height or 0) + if deferred_roots else inline_height), + screen_stack=background_screens, clear=clear, + excluded_regions=excluded_regions, + ) + if update is not None and not deferred_roots: + app._previous_inline_height = inline_height + else: + update = self._compositor.render_update( screen_stack=background_screens, - clear=clear, - ), - ) - app._previous_inline_height = inline_height - self._compositor._dirty_regions.clear() - else: - update = self._compositor.render_update( - screen_stack=background_screens - ) - app._display(self, update) + excluded_regions=excluded_regions, + ) + if update is not None: + app._display(self, update) + self._on_frame_published(deferred_roots) + if not self._compositor._interaction_deferred(*app.mouse_position): + app._update_mouse_over(self) + if background_screens: + for screen, _ in cohort: + screen._release_paint() self._dirty_widgets.clear() elif self in background_screens and self._compositor._dirty_regions: - if app.is_inline: - app.screen.refresh(*self._compositor._dirty_regions) - else: - self._set_dirty(*self._compositor._dirty_regions) - app.screen.refresh(*self._compositor._dirty_regions) - self._repaint_required = True - self._compositor._dirty_regions.clear() + # The foreground owns terminal publication of the same backdrop + # damage. Transfer the intent without consuming the held portion. + damage = self._compositor._exclude_regions( + self._compositor._dirty_regions, excluded_regions, + ) + if damage: + self._set_dirty(*damage) + app.screen.refresh(*damage) + self._compositor._dirty_regions = self._compositor._exclude_regions( + self._compositor._dirty_regions, damage, + ) self._dirty_widgets.clear() - app._update_mouse_over(self) + if not self._compositor._interaction_deferred(*app.mouse_position): + app._update_mouse_over(self) def _on_timer_update(self) -> None: """Called by the _update_timer.""" @@ -1287,19 +1316,38 @@ def _on_timer_update(self) -> None: if self._callbacks: self.call_next(self._invoke_and_clear_callbacks) + def _sender_refresh_pending(self, sender: Widget, roots: tuple[Widget, ...]) -> bool: + """Admit the original spatial sender, including its backdrop publication.""" + ancestry = set(sender.walk_ancestors(with_self=True)) + if ancestry.intersection(roots) or any( + sender in root.walk_ancestors(with_self=True) for root in roots + ): + return True + owner = sender.screen + for screen in (self, owner) if owner is not self else (self,): + if (screen._layout_required or screen._scroll_required or screen._recompose_required + or screen._repaint_required): + return True + if any(sender in widget.walk_ancestors(with_self=True) + for widget in screen._dirty_widgets): + return True + if screen._compositor.pending_for(sender): + return True + return False + async def _invoke_and_clear_callbacks(self) -> None: - """If there are scheduled callbacks to run, call them and clear - the callback queue.""" + """Keep held senders in the original queue; release independent senders.""" + index = 0 for _ in range(len(self._callbacks)): - if self.app._batch_count: - return - if self.is_current and self._refresh_pending: - self.check_idle() + if self.app._batch_count or index >= len(self._callbacks): return - if not self._callbacks: - return - callback, message_pump = self._callbacks.pop(0) - with message_pump._context(): + callback, sender = self._callbacks[index] + roots = tuple(root for _, roots in self._prepare_visible_screens() for root in roots) if self.is_current else () + if self.is_current and sender._after_refresh_pending(self, roots): + index += 1 + continue + self._callbacks.pop(index) + with sender._context(): await invoke(callback) def _invoke_later(self, callback: CallbackType, sender: MessagePump) -> None: @@ -1400,7 +1448,14 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non ) else: hidden, shown = self._compositor.reflow(self, size) - self._layout_widgets.clear() + mutation_roots = self._layout_mutation_roots() + self._layout_widgets = { + owner: held for owner, members in self._layout_widgets.items() + if (held := { + member for member in members + if set(member.walk_ancestors(with_self=True)).intersection(mutation_roots) + }) + } Hide = events.Hide Show = events.Show diff --git a/src/textual/widget.py b/src/textual/widget.py index 80760804c6..af44498da0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1402,6 +1402,10 @@ def render_str(self, text_content: str | Content) -> Content: return text_content return Content.from_markup(text_content) + def _after_refresh_pending(self, screen, roots: tuple[Widget, ...]) -> bool: + """A spatial sender borrows its original screen's geometry and damage.""" + return self.is_attached and screen._sender_refresh_pending(self, roots) + def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: """Arrange child widgets. @@ -1927,6 +1931,10 @@ def _get_box_model( Returns: The size and margin for this widget. """ + if self.is_attached and (box := self.screen._compositor.mutation_box(self)) is not None: + # Parent layouts measure the same committed box as Compositor; + # never descend through an actively changing child to size siblings. + return box nodes = self.__dict__.get("_nodes") revision = (self._layout_updates, nodes._updates if nodes is not None else 0) diff --git a/tests/test_batch_paint_admission.py b/tests/test_batch_paint_admission.py index 3fe7fb11f0..bfba560685 100644 --- a/tests/test_batch_paint_admission.py +++ b/tests/test_batch_paint_admission.py @@ -5,6 +5,7 @@ from textual.app import App, ComposeResult from textual.containers import Vertical from textual.screen import ModalScreen, Screen +from textual.widget import Widget from textual.widgets import Label @@ -13,7 +14,7 @@ def __init__(self) -> None: super().__init__() self.preparation_batches: list[int] = [] - def _prepare_compositor_refresh(self) -> bool: + def _prepare_compositor_refresh(self) -> tuple[Widget, ...]: self.preparation_batches.append(self.app._batch_count) return super()._prepare_compositor_refresh() @@ -99,9 +100,9 @@ def second() -> None: async def test_translucent_foreground_respects_background_resource_admission() -> None: class SourceScreen(ObservedScreen): - def _prepare_compositor_refresh(self) -> bool: - return (not self.query_one("#holder").lock.is_locked - and super()._prepare_compositor_refresh()) + def _prepare_compositor_refresh(self) -> tuple[Widget, ...]: + holder = self.query_one("#holder") + return (holder,) if holder.lock.is_locked else super()._prepare_compositor_refresh() class SourceApp(BatchPaintApp): def get_default_screen(self) -> Screen: @@ -122,7 +123,162 @@ def get_default_screen(self) -> Screen: foreground._compositor_refresh() assert not app.frames assert foreground._compositor._dirty_regions == damage - assert foreground._repaint_required foreground._compositor_refresh() assert app.frames assert not foreground._compositor._dirty_regions + + +class HeldVertical(Vertical): + """A real changing subtree refuses layout and paint until its owner releases.""" + + def arrange(self, size, optimal=False): + assert not self.lock.is_locked, "Layout descended into a mutation" + return super().arrange(size, optimal=optimal) + + def render_lines(self, crop): + assert not self.lock.is_locked, "Paint consumed a held source" + return super().render_lines(crop) + + +class IndependentScreen(ObservedScreen): + def __init__(self): + super().__init__() + self.publications = [] + + def _layout_mutation_roots(self): + return tuple(root for root in self.query(HeldVertical) if root.lock.is_locked) + + def _prepare_compositor_refresh(self): + return self._layout_mutation_roots() + + def _on_frame_published(self, deferred_roots): + self.publications.append(deferred_roots) + + +class IndependentApp(BatchPaintApp): + CSS = """ + HeldVertical { width: 20; height: auto; } + HeldVertical Label { height: 3; } + #sidebar { dock: right; width: 20; height: 8; } + """ + + def get_default_screen(self): + return IndependentScreen() + + def compose(self): + with HeldVertical(id="holder"): + yield Label("ORIGINAL_HELD", id="original") + yield Label("SIDEBAR_OLD", id="sidebar") + + +async def test_partial_publication_keeps_geometry_and_owner_callbacks(): + from textual._compositor import ChopsUpdate + from textual.geometry import Region + + app = IndependentApp() + held_done, parent_done, scene_done, sidebar_done = (asyncio.Event() for _ in range(4)) + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + screen = app.screen + holder = app.query_one(HeldVertical) + original = app.query_one("#original") + sidebar = app.query_one("#sidebar", Label) + compositor = screen._compositor + before = compositor.find_widget(holder) + original_geometry = compositor.find_widget(original) + original_hit = compositor.get_widget_at(15, 1)[0] + app.frames.clear() + screen.publications.clear() + async with holder.lock: + await holder.mount(Label("NEW_HELD", id="new")) + sidebar.update("SIDEBAR_NEW") + original.call_after_refresh(held_done.set) + holder.call_after_refresh(parent_done.set) + app.call_after_refresh(scene_done.set) + sidebar.call_after_refresh(sidebar_done.set) + await asyncio.wait_for(sidebar_done.wait(), 2) + assert not held_done.is_set() and not parent_done.is_set() and not scene_done.is_set() + assert compositor.find_widget(holder) == before + assert compositor.find_widget(original) == original_geometry + assert compositor.get_widget_at(1, 1)[0] is original + assert screen.publications and all(roots == (holder,) for roots in screen.publications) + assert compositor._dirty_regions + compositor._dirty_regions.add(compositor.size.region) + update = compositor.render_update(excluded_regions=compositor.deferred_regions((holder,))) + assert isinstance(update, ChopsUpdate) + cells = list(ChopsUpdate._span_cuts(update.spans, update.cuts, 0)) + assert all(not Region(x1, y, x2 - x1, 1).overlaps(before.visible_region) + for y, x1, x2 in cells) + assert "SIDEBAR_NEW" in update.render_segments(app.console) + assert "ORIGINAL_HELD" not in update.render_segments(app.console) + # Repeated idle delivery is quiescent: holding a callback never + # schedules itself, and held-only damage doesn't resume the timer. + await pilot.pause(0.05) + assert screen._update_timer._active.is_set() is False + # A sibling can acquire new layout overlapping held pixels. The + # terminal still shows the borrowed subtree there; hit lookup must + # not expose the sibling's unpainted part early. + sidebar.styles.width = 30 + 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) + assert held_done.is_set() and parent_done.is_set() + assert screen.publications[-1] == () + assert not compositor._dirty_regions + assert compositor.find_widget(holder).region.height == 6 + + +async def test_inline_partial_cells_preserve_held_content_and_clear_after_release(): + from textual._compositor import InlineUpdate + + app = IndependentApp() + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + compositor = app.screen._compositor + holder = app.query_one(HeldVertical) + async with holder.lock: + update = compositor.render_inline(app.size, clear=True, + excluded_regions=compositor.deferred_regions((holder,))) + assert isinstance(update, InlineUpdate) + rendered = update.render_segments(app.console) + assert "SIDEBAR_OLD" in rendered and "ORIGINAL_HELD" not in rendered + assert "\x1b[J" not in rendered + assert compositor._dirty_regions + rendered = compositor.render_inline(app.size, clear=True).render_segments(app.console) + assert "ORIGINAL_HELD" in rendered and "\x1b[J" in rendered + assert not compositor._dirty_regions + + +async def test_partial_translucent_publication_never_reads_held_backdrop(): + from textual._compositor import ChopsUpdate + from textual.geometry import Region + + app = IndependentApp() + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + source = app.screen + holder = app.query_one(HeldVertical) + held = holder.region + await app.push_screen(ModalScreen()) + await pilot.pause() + foreground = app.screen + async with holder.lock: + compositor = foreground._compositor + compositor._dirty_regions.add(compositor.size.region) + regions = source._compositor.deferred_regions((holder,)) + # Exercise the original Screen publication including its nested + # BackgroundScreen renderer, not only the isolated damage function. + app.frames.clear() + foreground._compositor_refresh() + assert app.frames + assert compositor._dirty_regions + with source._compositor._using_exclusions(regions): + update = compositor.render_update(full=True, screen_stack=[source], excluded_regions=regions) + assert isinstance(update, ChopsUpdate) + assert all(not Region(x1, y, x2 - x1, 1).overlaps(held) + for y, x1, x2 in ChopsUpdate._span_cuts(update.spans, update.cuts, 0)) + assert "SIDEBAR_OLD" in update.render_segments(app.console) + foreground.refresh() + await pilot.pause() + assert not foreground._compositor._dirty_regions