diff --git a/evidence/native-style-layout-publication-20261004/NATIVE-APP-QUALIFIED.json b/evidence/native-style-layout-publication-20261004/NATIVE-APP-QUALIFIED.json new file mode 100644 index 0000000000..f9e62a4d22 --- /dev/null +++ b/evidence/native-style-layout-publication-20261004/NATIVE-APP-QUALIFIED.json @@ -0,0 +1,35 @@ +{ + "scope": "Native55 source-retirement/frame-request family", + "production_head": "a3fe919a4fed230de0380f2587208f909849379f", + "base": "c1c1ebe5c40499f75dc370c26a7818588114e71d", + "state": "NATIVE_APP_QUALIFIED_INSTALLED_PENDING", + "app_controls": { + "command": "PYTHONPATH=src PYTHONDONTWRITEBYTECODE=1 timeout 60 python -m pytest -q -p no:cacheprovider tests/css/test_programmatic_style_changes.py", + "exit": 0, + "passed": 14, + "elapsed_seconds": 1.6 + }, + "packaged_ratchet": { + "exit": 0, + "positive": {}, + "nonzero": { + "GodClassExcess:src/textual/css/styles.py::Styles": -1 + } + }, + "production_diff": { + "files": 4, + "added": 23, + "deleted": 24 + }, + "evidence": { + "native55-final-app-batch.log": { + "path": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/native55-final-app-batch.log", + "sha256": "1d4524fd568da8973a7da69bd2746b8e9cca768cef9c7d41729fbb9482edc325" + }, + "native55-ratchet.json": { + "path": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/native55-ratchet.json", + "sha256": "67007b3273329121349d605412489386596a2a44420e0ba9ce2b7a839d5822da" + } + }, + "installed_qualification": "Pending one changed workflow with Heisenberg; existing F4 holder is exclusively borrowed. No installed writes/App/capture by this branch. No CPU, latency or smoothness claim." +} diff --git a/evidence/native-style-layout-publication-20261004/SCOPE.md b/evidence/native-style-layout-publication-20261004/SCOPE.md new file mode 100644 index 0000000000..ef497edd23 --- /dev/null +++ b/evidence/native-style-layout-publication-20261004/SCOPE.md @@ -0,0 +1,84 @@ +# Native style mutation and layout request have distinct lifetimes + +Source: Styles._update_rules commits authored inputs, then DOMNode._style_rules_updated +retires descendant measurements and ancestor geometry through the original hooks. +Styles._refresh then calls Widget.refresh(layout=True), which treats the request +as another source mutation and runs _invalidate_layout/_measurement_updated again. +Raw style writes must remain synchronous source invalidation without unsolicited +refresh; imperative content refresh must still invalidate before an await. + +Use the existing style-notification / Widget layout publication family to carry +already-published style requests. Keep source mutation owned by Styles and ordinary +Widget input mutation owned by its current public refresh contract. Delete repeated +retirement from style scheduling, not legitimate layout or actual content writes in +leaf notify hooks. All original native/Toad overrides retain their public contract; scheduling callers migrate coherently; +no new mirror, cache, flag, typed wrapper, scene map, timer or alternate renderer. +The existing public style callback keeps its signature and behavior; source review +rejected introducing a new keyword to existing subclass callbacks. + +Native ownership: Widget/DOM style-to-layout scheduling, Styles notification callers, +original native widget overrides. Heisenberg owns Toad source/admission/preparation/ +paint/retirement and its eight style-notification overrides; coordinate the exact +shared boundary directly. No edits to his worktree. Native52/53 source and installed +qualification stay frozen on main and are not a performance finish line. + +Current main c1c1ebe5; existing NRA Package complete native249 and Toad288 parsed with +zero omissions. Source activation from original422 is not CPU dominance or a latency +measurement. Read all declarations, writes and hooks before implementation. Coherent +family implementation first; affected controls and one changed installed user path +last with the workflow owner. No old film, new worktree/environment/native copy or +parallel measurement project. Full structural UI responsiveness remains active. + +## Working owner implementation + +Existing Widget now owns `_request_layout(required: bool = True)`: it joins the +actual native `_layout_required` work flag without retiring source inputs or +triggering idle on its own. DOMNode supplies the non-rendered no-op hook. Public +Widget.refresh(layout=True) still calls synchronous _invalidate_layout FIRST, then +requests the original Layout publication and preserves its ordinary idle/paint +lifetime. Styles._refresh requests that same publication after existing authored +source invalidation, then retains original paint/idle/notify behavior. Child +inherited refresh requests and DOMNode.reset_styles use the same owner. The third +foreign flag write was reset_styles, not a _refresh_styles method. + +No notify_style_update signature changes: all nine native/eight Toad leaf hooks +retain their independent content/style/worker preparation behavior. New content +mutations in those hooks may legitimately invalidate again; only treating the +same style-frame request as another input mutation is deleted. BodyMeasurement's +synchronous invalidation/participation change remains untouched, as do its genuine +refresh(layout=True) tree publication consumers. No copied frame flags or source +frontier, no cache/map/type, no source/receipt/follow semantic bypass. + +The first published checkpoint was source-only. The completed native App batch +is recorded below; installed Toad qualification remains pending. No old film or +environment/package launch was made for this source pass. + +## Complete caller closure and final native App batch + +App's existing refresh forwards to the current Screen. App therefore overrides +the same _request_layout hook to forward already-invalidated style publication; +its public refresh(layout=True) still retires actual Screen content inputs. +The mounted-App control verifies source retirement once and actual Screen +publication, preventing a silent App-level lost-layout regression. + +Screen's remaining flag writes belong to its native frame lifetime: _on_layout +joins child Layout messages, _on_timer_update consumes the admitted reflow, and +inactive presentation retirement requests reconstruction of released resources. +Screen prevents Widget's default idle handler and consumes its own pending work. +Those decisions remain with Screen. No unrelated flag/map/lifetime is unified. + +The native family closes IMPL-13/BOUND-2: scheduling uses the existing layout +publication owner instead of repeating its source-retirement algorithm. Four +production files change 23 added/24 deleted lines; no source counters removed. +The existing programmatic-style App batch plus two concrete lifetime controls +passed 14 cases in 1.60s. They cover native grid/align children, cohort atomicity, +offset retained paint/hits, raw-style synchronous geometry invalidation without +unsolicited layout, separate frame publication, actual new-content height/paint, +and App-to-Screen layout delivery. This is source App qualification, not an +installed Toad recording or demonstrated CPU gain. The changed installed workflow +remains with Heisenberg's existing holder after its current F4 lease is returned. + +The original packaged F0 ratchet exits 0 against main c1c1: no admitted +measure grows; Styles GodClassExcess decreases by one, all other deltas zero. +App/DOM/Widget source-to-frame hooks replace existing family work; no waiver, +threshold or scanner change. Hosted Debt must bind the final published head. diff --git a/evidence/native-style-layout-publication-20261004/owner-consumers.json b/evidence/native-style-layout-publication-20261004/owner-consumers.json new file mode 100644 index 0000000000..e3e157f87b --- /dev/null +++ b/evidence/native-style-layout-publication-20261004/owner-consumers.json @@ -0,0 +1,922 @@ +{ + "before": { + "revision": "c1c1ebe5c40499f75dc370c26a7818588114e71d", + "modules": 249, + "omitted": [], + "sites": [ + { + "path": "src/textual/app.py", + "line": 3825, + "call": "self.screen.refresh(repaint=repaint, layout=layout)" + }, + { + "path": "src/textual/app.py", + "line": 4808, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/app.py", + "line": 3703, + "call": "widget.notify_style_update()" + }, + { + "path": "src/textual/app.py", + "line": 4447, + "call": "parent.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 154, + "call": "obj.refresh(layout=self.layout, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 260, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 307, + "call": "obj.refresh(layout=True, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 657, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 688, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 741, + "call": "obj.refresh(layout=True, children=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 890, + "call": "obj.refresh(layout=self._layout, children=self._refresh_children, parent=self._refresh_parent)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 150, + "call": "obj.refresh(layout=self.layout, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 224, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 288, + "call": "obj.refresh(layout=True, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 363, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 496, + "call": "obj.refresh(layout=layout)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 569, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 572, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 610, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 626, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 724, + "call": "obj.refresh(layout=True, children=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 787, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 942, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 947, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 963, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 387, + "call": "obj.refresh(layout=self.affects_geometry(current_value, new_value))" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 790, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 813, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 968, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 971, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/query.py", + "line": 453, + "call": "node.refresh(repaint=repaint, layout=layout, recompose=recompose)" + }, + { + "path": "src/textual/css/query.py", + "line": 436, + "call": "node.refresh(layout=True)" + }, + { + "path": "src/textual/css/styles.py", + "line": 1060, + "call": "node.notify_style_update()" + }, + { + "path": "src/textual/css/styles.py", + "line": 1064, + "call": "node.refresh(layout=any((layout for layout, _, _, _ in requests)), repaint=any((repaint for _, _, _, repaint in requests)))" + }, + { + "path": "src/textual/css/styles.py", + "line": 1574, + "call": "self._inline_styles.refresh(layout=layout, children=children, parent=parent, repaint=repaint)" + }, + { + "path": "src/textual/css/styles.py", + "line": 990, + "call": "node._style_rules_updated(Styles._revision, geometry)" + }, + { + "path": "src/textual/css/styles.py", + "line": 1073, + "call": "child.notify_style_update()" + }, + { + "path": "src/textual/css/styles.py", + "line": 1074, + "call": "child.refresh(layout=child_layout, repaint=child_repaint)" + }, + { + "path": "src/textual/dom.py", + "line": 172, + "definition": "_style_rules_updated" + }, + { + "path": "src/textual/dom.py", + "line": 705, + "definition": "notify_style_update" + }, + { + "path": "src/textual/dom.py", + "line": 531, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/dom.py", + "line": 1045, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/dom.py", + "line": 1413, + "write": "node._layout_required = True" + }, + { + "path": "src/textual/dom.py", + "line": 1830, + "call": "self._inline_styles.refresh(layout=True)" + }, + { + "path": "src/textual/reactive.py", + "line": 432, + "call": "obj.refresh(repaint=self._repaint, layout=request_layout, recompose=self._recompose)" + }, + { + "path": "src/textual/screen.py", + "line": 1604, + "write": "self._layout_required = True" + }, + { + "path": "src/textual/screen.py", + "line": 1479, + "write": "self._layout_required = True" + }, + { + "path": "src/textual/screen.py", + "line": 1274, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 344, + "definition": "_measurement_updated" + }, + { + "path": "src/textual/widget.py", + "line": 351, + "definition": "_invalidate_layout" + }, + { + "path": "src/textual/widget.py", + "line": 4965, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widget.py", + "line": 514, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 859, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1456, + "call": "self._invalidate_layout()" + }, + { + "path": "src/textual/widget.py", + "line": 1829, + "call": "previous.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1830, + "call": "parent.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 4598, + "write": "self._layout_required = True" + }, + { + "path": "src/textual/widget.py", + "line": 4969, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widget.py", + "line": 883, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1763, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 4596, + "call": "self._invalidate_layout()" + }, + { + "path": "src/textual/widget.py", + "line": 4850, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 179, + "call": "self._parent.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1123, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1124, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1830, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1868, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_digits.py", + "line": 75, + "call": "self.refresh(layout=layout_required)" + }, + { + "path": "src/textual/widgets/_footer.py", + "line": 424, + "call": "key.refresh(layout=changed_size)" + }, + { + "path": "src/textual/widgets/_input.py", + "line": 550, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_log.py", + "line": 95, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_log.py", + "line": 97, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_markdown.py", + "line": 922, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_markdown.py", + "line": 926, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 708, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 710, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 410, + "call": "self.refresh(layout=self.styles.auto_dimensions)" + }, + { + "path": "src/textual/widgets/_pretty.py", + "line": 56, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_rich_log.py", + "line": 128, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_rich_log.py", + "line": 129, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_static.py", + "line": 77, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_static.py", + "line": 110, + "call": "self.refresh(layout=layout)" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 816, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 818, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_toggle_button.py", + "line": 192, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1467, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1079, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1468, + "call": "super().notify_style_update()" + } + ] + }, + "after": { + "revision": "a3fe919a4fed230de0380f2587208f909849379f", + "modules": 249, + "omitted": [], + "sites": [ + { + "path": "src/textual/app.py", + "line": 3802, + "definition": "_request_layout" + }, + { + "path": "src/textual/app.py", + "line": 3804, + "call": "self.screen._request_layout(required)" + }, + { + "path": "src/textual/app.py", + "line": 3825, + "call": "self.screen.refresh(repaint=repaint, layout=layout)" + }, + { + "path": "src/textual/app.py", + "line": 4808, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/app.py", + "line": 3703, + "call": "widget.notify_style_update()" + }, + { + "path": "src/textual/app.py", + "line": 4447, + "call": "parent.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 154, + "call": "obj.refresh(layout=self.layout, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 260, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 307, + "call": "obj.refresh(layout=True, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 657, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 688, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 741, + "call": "obj.refresh(layout=True, children=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 890, + "call": "obj.refresh(layout=self._layout, children=self._refresh_children, parent=self._refresh_parent)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 150, + "call": "obj.refresh(layout=self.layout, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 224, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 288, + "call": "obj.refresh(layout=True, children=self.refresh_children)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 363, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 496, + "call": "obj.refresh(layout=layout)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 569, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 572, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 610, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 626, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 724, + "call": "obj.refresh(layout=True, children=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 787, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 942, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 947, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 963, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 387, + "call": "obj.refresh(layout=self.affects_geometry(current_value, new_value))" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 790, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 813, + "call": "obj.refresh(layout=True, repaint=False)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 968, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/_style_properties.py", + "line": 971, + "call": "obj.refresh(layout=True)" + }, + { + "path": "src/textual/css/query.py", + "line": 453, + "call": "node.refresh(repaint=repaint, layout=layout, recompose=recompose)" + }, + { + "path": "src/textual/css/query.py", + "line": 436, + "call": "node.refresh(layout=True)" + }, + { + "path": "src/textual/css/styles.py", + "line": 1060, + "call": "node.notify_style_update()" + }, + { + "path": "src/textual/css/styles.py", + "line": 1064, + "call": "node._request_layout(any((layout for layout, _, _, _ in requests)))" + }, + { + "path": "src/textual/css/styles.py", + "line": 1573, + "call": "self._inline_styles.refresh(layout=layout, children=children, parent=parent, repaint=repaint)" + }, + { + "path": "src/textual/css/styles.py", + "line": 990, + "call": "node._style_rules_updated(Styles._revision, geometry)" + }, + { + "path": "src/textual/css/styles.py", + "line": 1071, + "call": "child.notify_style_update()" + }, + { + "path": "src/textual/css/styles.py", + "line": 1072, + "call": "child._request_layout(child_layout)" + }, + { + "path": "src/textual/dom.py", + "line": 172, + "definition": "_style_rules_updated" + }, + { + "path": "src/textual/dom.py", + "line": 705, + "definition": "_request_layout" + }, + { + "path": "src/textual/dom.py", + "line": 708, + "definition": "notify_style_update" + }, + { + "path": "src/textual/dom.py", + "line": 531, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/dom.py", + "line": 1045, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/dom.py", + "line": 1830, + "call": "self._inline_styles.refresh(layout=True)" + }, + { + "path": "src/textual/dom.py", + "line": 1413, + "call": "node._request_layout()" + }, + { + "path": "src/textual/reactive.py", + "line": 432, + "call": "obj.refresh(repaint=self._repaint, layout=request_layout, recompose=self._recompose)" + }, + { + "path": "src/textual/screen.py", + "line": 1604, + "write": "self._layout_required = True" + }, + { + "path": "src/textual/screen.py", + "line": 1479, + "write": "self._layout_required = True" + }, + { + "path": "src/textual/screen.py", + "line": 1274, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 344, + "definition": "_measurement_updated" + }, + { + "path": "src/textual/widget.py", + "line": 351, + "definition": "_invalidate_layout" + }, + { + "path": "src/textual/widget.py", + "line": 4962, + "definition": "_request_layout" + }, + { + "path": "src/textual/widget.py", + "line": 4965, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widget.py", + "line": 512, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 4963, + "write": "self._layout_required |= required" + }, + { + "path": "src/textual/widget.py", + "line": 857, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1454, + "call": "self._invalidate_layout()" + }, + { + "path": "src/textual/widget.py", + "line": 1827, + "call": "previous.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1828, + "call": "parent.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 4969, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widget.py", + "line": 881, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 1761, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widget.py", + "line": 4594, + "call": "self._invalidate_layout()" + }, + { + "path": "src/textual/widget.py", + "line": 4595, + "call": "self._request_layout()" + }, + { + "path": "src/textual/widget.py", + "line": 4847, + "write": "self._layout_required = False" + }, + { + "path": "src/textual/widget.py", + "line": 179, + "call": "self._parent.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1123, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1124, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1830, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 1868, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_digits.py", + "line": 75, + "call": "self.refresh(layout=layout_required)" + }, + { + "path": "src/textual/widgets/_footer.py", + "line": 424, + "call": "key.refresh(layout=changed_size)" + }, + { + "path": "src/textual/widgets/_input.py", + "line": 550, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_log.py", + "line": 95, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_log.py", + "line": 97, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_markdown.py", + "line": 922, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_markdown.py", + "line": 926, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 708, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 710, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_option_list.py", + "line": 410, + "call": "self.refresh(layout=self.styles.auto_dimensions)" + }, + { + "path": "src/textual/widgets/_pretty.py", + "line": 56, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_rich_log.py", + "line": 128, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_rich_log.py", + "line": 129, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_static.py", + "line": 77, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_static.py", + "line": 110, + "call": "self.refresh(layout=layout)" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 816, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 818, + "call": "super().notify_style_update()" + }, + { + "path": "src/textual/widgets/_toggle_button.py", + "line": 192, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1467, + "definition": "notify_style_update" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1079, + "call": "self.refresh(layout=True)" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1468, + "call": "super().notify_style_update()" + } + ] + }, + "limits": "AST shows syntax and declared inheritance; it does not resolve arbitrary dynamic method replacement. All native9/Toad8 style hooks retain their existing ABI and source-specific behavior. Screen consumes native reflow; Widget posts child Layout; App delegates current-screen style work." +} diff --git a/src/textual/app.py b/src/textual/app.py index f7bc8cdaae..2679dd5416 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3799,6 +3799,10 @@ async def _on_exit_app(self) -> None: self._begin_batch() # Prevent repaint / layout while shutting down self._message_queue.put_nowait(None) + def _request_layout(self, required: bool = True) -> None: + if self._screen_stack: + self.screen._request_layout(required) + def refresh( self, *, @@ -3806,15 +3810,11 @@ def refresh( layout: bool = False, recompose: bool = False, ) -> Self: - """Refresh the entire screen. + """Refresh the current screen and return this App. - Args: - repaint: Repaint the widget (will call render() again). - layout: Also layout widgets in the view. - recompose: Re-compose the widget (will remove and re-mount children). - - Returns: - The `App` instance. + ``layout=True`` retires actual screen layout inputs; style scheduling + uses _request_layout after its source has already changed. ``repaint`` + invalidates rendered content; ``recompose`` removes and mounts children. """ if recompose: self._recompose_required = recompose diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 3d9119a2d3..211432aadb 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1061,17 +1061,16 @@ def _refresh(self, requests: list[tuple[bool, bool, bool, bool]]) -> None: parent_requests = [repaint for _, _, parent, repaint in requests if parent] if parent_requests and node._parent is not None: node._parent.refresh(repaint=any(parent_requests)) - node.refresh( - layout=any(layout for layout, _, _, _ in requests), - repaint=any(repaint for _, _, _, repaint in requests), - ) + node._request_layout(any(layout for layout, _, _, _ in requests)) + node.refresh(repaint=any(repaint for _, _, _, repaint in requests)) child_requests = [(layout, repaint) for layout, children, _, repaint in requests if children] if child_requests: child_layout = any(layout for layout, _ in child_requests) child_repaint = any(repaint for _, repaint in child_requests) for child in node.walk_children(with_self=False, reverse=True): child.notify_style_update() - child.refresh(layout=child_layout, repaint=child_repaint) + child._request_layout(child_layout) + child.refresh(repaint=child_repaint) def reset(self) -> None: """Reset the rules to initial state.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index 05fc706836..c169552889 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -702,11 +702,11 @@ def _post_mount(self): _rich_traceback_omit = True Reactive._initialize_object(self) - def notify_style_update(self) -> None: - """Called after styles are updated. + def _request_layout(self, required: bool = True) -> None: + """Non-rendered DOM nodes have no layout publication.""" - Implement this in a subclass if you want to clear any cached data when the CSS is reloaded. - """ + def notify_style_update(self) -> None: + """Clear subclass resources after original styles change.""" @property def _node_bases(self) -> Sequence[Type[DOMNode]]: @@ -1410,7 +1410,7 @@ def reset_styles(self) -> None: node._css_styles.reset() if isinstance(node, Widget): node._set_dirty() - node._layout_required = True + node._request_layout() def _add_child(self, node: Widget) -> None: """Add a new child node. diff --git a/src/textual/widget.py b/src/textual/widget.py index 78ec964737..9c5c3c0439 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -349,11 +349,9 @@ def _measurement_updated(self) -> None: self._clear_arrangement_cache() def _invalidate_layout(self) -> None: - """Publish changed layout inputs before the idle Layout notification. + """Retire authored layout inputs before requesting a frame. - Scene placement still invalidates every enclosing capture. Measurement - propagation retains the original fixed-size ancestor boundary; scrolling - changes only the scene and never enters this source lifetime. + Capture invalidation spans ancestors; measurement stops at fixed size. """ self._invalidate_subtree_geometry() self._measurement_updated() @@ -4594,8 +4592,7 @@ def refresh( if layout: self._invalidate_layout() - if layout and not self._layout_required: - self._layout_required = True + self._request_layout() if recompose: self._recompose_required = True @@ -4962,6 +4959,9 @@ async def prepare_input(self, event: events.InputEvent) -> None: that geometry; widget mouse handlers run after target selection. """ + def _request_layout(self, required: bool = True) -> None: + self._layout_required |= required + def notify_style_update(self) -> None: self._rich_style_cache.clear() self._visual_style_cache.clear() diff --git a/tests/css/test_programmatic_style_changes.py b/tests/css/test_programmatic_style_changes.py index 7a9794adc2..0bd44a477b 100644 --- a/tests/css/test_programmatic_style_changes.py +++ b/tests/css/test_programmatic_style_changes.py @@ -5,6 +5,52 @@ from textual.widgets import Label +async def test_style_publication_preserves_source_and_content_lifetimes(): + """A frame request must not retire measurements twice or hide new content.""" + from textual.css.scalar import Scalar + + app = App() + async with app.run_test(size=(40, 12)) as pilot: + widget = Label("one") + await app.mount(widget) + await pilot.pause() + updates = widget._layout_updates + geometry = app.screen._geometry_revision + widget._inline_styles.set_rule("width", Scalar.parse("12")) + assert widget._layout_updates == updates + 1 + assert app.screen._geometry_revision > geometry + assert not widget._layout_required + widget._inline_styles.refresh(layout=True, repaint=False) + assert widget._layout_updates == updates + 1 + await pilot.pause() + assert widget.size.width == 12 + + updates = widget._layout_updates + geometry = app.screen._geometry_revision + widget.update("one\ntwo\nthree") + assert widget._layout_updates == updates + 1 + assert app.screen._geometry_revision > geometry + await pilot.pause() + assert widget.size.height == 3 + assert "three" in widget.render_line(2).text + + +async def test_app_style_publication_keeps_current_screen_layout(): + """App style damage must reach its current Screen without retiring it twice.""" + app = App() + async with app.run_test(size=(40, 12)) as pilot: + widget = Label("current screen") + await app.mount(widget) + await pilot.pause() + updates = app.screen._layout_updates + app.styles.padding = (1, 2) + assert app.screen._layout_updates == updates + 1 + assert app.screen._layout_required + await pilot.pause() + assert not app.screen._layout_required + assert widget in app.screen._compositor.visible_widgets + + @pytest.mark.parametrize( "style, value", [