diff --git a/evidence/tree-cursor-projection-20261004/FILEWHEEL.json b/evidence/tree-cursor-projection-20261004/FILEWHEEL.json new file mode 100644 index 00000000000..d153bfda5aa --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/FILEWHEEL.json @@ -0,0 +1,42 @@ +{ + "buildhead": "d0c11d918f59fbf9e71d1fb0fdb67975421479a2", + "wheel": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/text58-tree-cursor-filewheel-20261004/textual-8.2.8-py3-none-any.whl", + "wheel_sha256": "0ffc78c3a7d56212479e016e13683e5d921a0b3fc9fbecfd1f5151994ac2862f", + "wheel_bytes": 768032, + "source_files": 266, + "source_byte_equal": true, + "source_inventory": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/text58-tree-cursor-filewheel-20261004/source-inventory.json", + "source_inventory_sha256": "05036e0b2119604a98c7833e7321b89b17e4a7a7adb3f450c33c13411717d077", + "requires_dist": [ + "Requires-Python: >=3.9,<4.0", + "Requires-Dist: markdown-it-py[linkify] (>=2.1.0)", + "Requires-Dist: mdit-py-plugins", + "Requires-Dist: platformdirs (>=3.6.0,<5)", + "Requires-Dist: pygments (>=2.19.2,<3.0.0)", + "Requires-Dist: rich (>=14.2.0)", + "Requires-Dist: tree-sitter (>=0.25.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-bash (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-css (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-go (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-html (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-java (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-javascript (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-json (>=0.24.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-markdown (>=0.3.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-python (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-regex (>=0.24.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-rust (>=0.23.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-sql (>=0.3.11) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-toml (>=0.6.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-xml (>=0.7.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: tree-sitter-yaml (>=0.6.0) ; (python_version >= \"3.10\") and (extra == \"syntax\")", + "Requires-Dist: typing-extensions (>=4.4.0,<5.0.0)" + ], + "dependencies_unchanged_vs_native55": true, + "backend": "poetry.core.masonry.api", + "backend_readonly_path": "/home/ts/.cache/uv/archive-v0/rA45km4nFPRmVeSg", + "installed_writes": false, + "new_environment": false, + "native_copy": false, + "qualification": "native App cursor family only; Einstein installed F3 gate pending" +} diff --git a/evidence/tree-cursor-projection-20261004/NATIVE-APP-QUALIFIED.json b/evidence/tree-cursor-projection-20261004/NATIVE-APP-QUALIFIED.json new file mode 100644 index 00000000000..9d5b013ee95 --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/NATIVE-APP-QUALIFIED.json @@ -0,0 +1,24 @@ +{ + "original_batch": { + "log": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/native58-final-app-batch.log", + "sha256": "5768f4d4374c9726d467778293e9676b8ad894b0f346c55bff5a904e94dcc76a", + "result": "28 passed; 1 test-assumption failure", + "seconds": 3.26 + }, + "contract_followup": { + "log": "/home/ts/wt/textual-native-subtree-strips-20261002/.artifacts/native58-contract-correction.log", + "sha256": "964ca6b97369074902f81b50f7fcdd26ee2b9f5faca63de9e08fb47476e4254c", + "result": "..... [100%]\n5 passed in 0.45s" + }, + "concrete_risks": [ + "unbuilt offscreen node selection and scroll", + "restoring same selected data into native replacement Tree", + "line navigation after source mutation rebases cursor", + "selection/reset events and original hidden/foreign contracts", + "DirectoryTree reload and path restoration" + ], + "installed_f3": "pending sole Einstein owner gate", + "physical_performance": "unqualified", + "new_environment": false, + "installed_writes": false +} diff --git a/evidence/tree-cursor-projection-20261004/SCOPE.md b/evidence/tree-cursor-projection-20261004/SCOPE.md new file mode 100644 index 00000000000..9efa7cc4fe4 --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/SCOPE.md @@ -0,0 +1,25 @@ +# Tree cursor projection + +Tree owns the lazy `_tree_lines` projection and assigns node lines in `_build`. Cursor validation builds that projection after its argument has already been evaluated. An unbuilt node therefore contributes -1 even when the build makes it visible, and validation selects line zero. + +Acquire the original projection before reading node or current cursor lines. Close move/select, scroll-to-node, line-relative navigation and DirectoryTree restoration through the existing Tree methods. Preserve original None/reset clamping, collapsed/foreign-node behavior, selection events, animation and public node line storage; no expansion, copied selection or callback retry. + +Source AST uses the existing refactor-audit Package parser over all native and Toad production modules. Dynamic receiver resolution is not proven by attribute syntax. Source reasoning precedes implementation; one affected native mounted-App batch follows the complete change. Einstein owns the changed installed F3 journey and its original reader11 failure. No installed holder access is granted here. + +Native55/CI57 and their original qualifications remain unchanged. Full performance and physical motion are outside this narrow fix. + +## Implemented closure + +Tree now acquires `_tree_lines` before move-cursor reads either the requested node ordinal or the prior cursor ordinal. Scroll-to-node does likewise. Line navigation resolves the projection before its equality shortcut; up/down/page arithmetic follows lazy cursor rebasing. Single-step branches are reduced to the same original assignments, preserving notifications and clamps. TreeNode.line acquires its original Tree projection, so public restoration can retain separate selection and viewport intent. DirectoryTree reload reads its existing selected node instead of decoding an old numeric cursor through a newly built projection. + +No new owner, cache, flag, timer, retry or expansion policy. Existing select-node, sibling navigation and ContextTree restoration reuse move-cursor. DirectoryTree's existing final line-acquisition restoration remains correct. None resets continue through the original validator (root for nonempty Trees); unselect keeps its bypassed -1 and intentionally does not run cursor-node watchers; hidden/unrelated nodes retain their original raw-line contract. + +Two production files: 16 additions, 16 deletions. Before/after AST: native249 modules and Toad288 modules, zero omissions. Syntactic names from non-Tree APIs are listed, not counted as dynamic Tree resolution. BOUND-2 applies: callers previously read cached external projection before invoking its owner; now that owner supplies projection before the decision. + +Validation follows the coherent source: existing selection-message/reset controls plus mounted unbuilt/offscreen movement, public line restoration, actual Tree replacement with same selected data, lazy-rebased up/down/pages and line equality, hidden/foreign behavior, DirectoryTree reload/path lifecycle. Einstein's original reader11 negative and next installed F3 journey remain separate acceptance. + +## Native App qualification + +One affected batch: 28 passed / 1 assertion failed in 3.26 seconds. The failing assertion assumed `unselect()` clears cursor_node immediately; existing DOM.set_reactive explicitly skips validation and watchers. Product code was not changed to satisfy it. The corrected original-contract case and four line-rebase cases passed in the bounded follow-up. Original negative log retained. Formatting of the one navigation expression adds a local scalar; it does not retain state or change its assignment. + +No installed access, provider, UI recorder, new environment or native copy. These are actual native App cases, not installed F3 acceptance. Einstein owns that remaining boundary. Full performance remains open. diff --git a/evidence/tree-cursor-projection-20261004/after-consumers.json b/evidence/tree-cursor-projection-20261004/after-consumers.json new file mode 100644 index 00000000000..d3769b04de4 --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/after-consumers.json @@ -0,0 +1,675 @@ +{ + "head": "d0c11d918f59fbf9e71d1fb0fdb67975421479a2", + "modules": 249, + "omissions": [], + "sites": [ + { + "path": "src/textual/_compositor.py", + "line": 302, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/_compositor.py", + "line": 347, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/_compositor.py", + "line": 424, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/digits.py", + "line": 231, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/gradient.py", + "line": 75, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 77, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 136, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 86, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/scrollbar.py", + "line": 193, + "expression": "_Segment.line", + "context": "Load" + }, + { + "path": "src/textual/strip.py", + "line": 53, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2724, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2748, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2787, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2796, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 369, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 326, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 327, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 327, + "expression": "best_found.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 558, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 622, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 626, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 630, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 674, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 601, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_rule.py", + "line": 106, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 1205, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2157, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2205, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2272, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2303, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2323, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2343, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2359, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2375, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2403, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2431, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2471, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2485, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2036, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2716, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 129, + "expression": "self._line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 161, + "expression": "self._tree._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 162, + "expression": "self._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 972, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 973, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 974, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1179, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1209, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1210, + "expression": "node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1317, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1476, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1477, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1482, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1483, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1488, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1491, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1499, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1504, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1513, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 514, + "expression": "self._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 974, + "expression": "node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 995, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1004, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1010, + "expression": "Tree.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1236, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1252, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1267, + "expression": "node._line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1295, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1478, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1484, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1489, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1490, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1492, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1497, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1498, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1500, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1505, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1514, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1533, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1602, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 846, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 976, + "expression": "self.scroll_to_node", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 996, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1030, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1107, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1127, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1297, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1297, + "expression": "self.cursor_node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1298, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1299, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1426, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1483, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1519, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1519, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1536, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1536, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1547, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1553, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 992, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1062, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1466, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1561, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1563, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1575, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1573, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 978, + "expression": "self.cursor_line", + "context": "Load" + } + ], + "ownership": { + "projection": "Tree._tree_lines -> Tree._build", + "line_writes": "TreeNode.__init__ initial -1; Tree._build visible ordinal", + "selection": "Tree.cursor_node / watch_cursor_line", + "clamp": "Tree.validate_cursor_line unchanged", + "source_membership": "TreeNode child/expand/remove owners unchanged" + }, + "changed_consumers": [ + "TreeNode.line", + "Tree.move_cursor", + "Tree.move_cursor_to_line", + "Tree.scroll_to_node", + "Tree.action_cursor_up", + "Tree.action_cursor_down", + "Tree.action_page_up", + "Tree.action_page_down", + "DirectoryTree._reload" + ], + "unchanged_derived_consumers": [ + "Tree.select_node", + "Tree.action_cursor_parent and sibling methods", + "DirectoryTree._loader move_cursor", + "DirectoryTree._reload restoration already acquires _tree_lines before assigning line", + "Toad ContextTree._restore_cursor -> move_cursor", + "Toad ProjectTreeIntent.restore -> selection.line, preserves separate saved viewport" + ], + "limits": [ + "AST attribute matches include unrelated Segment/DataTable/TextArea/ANSI names; sites read semantically, not dynamic receiver proof.", + "Hidden or foreign node._line behavior retained; no new expansion/removal policy.", + "Node refresh/render caches read existing private metadata; no cursor-intent decision.", + "Explicit line APIs keep ordinal arguments; cursor navigation acquires before calculating those arguments." + ] +} diff --git a/evidence/tree-cursor-projection-20261004/before-consumers.json b/evidence/tree-cursor-projection-20261004/before-consumers.json new file mode 100644 index 00000000000..e01e6321ccc --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/before-consumers.json @@ -0,0 +1,918 @@ +[ + { + "repo": "/home/ts/wt/textual-native-subtree-strips-20261002", + "head": "8032117fedd8992da21c7ebc3267c4618737a500", + "modules": 249, + "omissions": [], + "sites": [ + { + "path": "src/textual/_compositor.py", + "line": 302, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/_compositor.py", + "line": 347, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/_compositor.py", + "line": 424, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/digits.py", + "line": 231, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/gradient.py", + "line": 75, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 77, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 136, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/renderables/sparkline.py", + "line": 86, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/scrollbar.py", + "line": 193, + "expression": "_Segment.line", + "context": "Load" + }, + { + "path": "src/textual/strip.py", + "line": 53, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2724, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2748, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2787, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_data_table.py", + "line": 2796, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 370, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 274, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 327, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 328, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 328, + "expression": "best_found.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 275, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_directory_tree.py", + "line": 559, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 622, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 626, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 630, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 674, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_masked_input.py", + "line": 601, + "expression": "self._template.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_rule.py", + "line": 106, + "expression": "Segment.line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 1205, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2157, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2205, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2272, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2303, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2323, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2343, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2359, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2375, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2403, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2431, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2471, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2485, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2036, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_text_area.py", + "line": 2716, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 129, + "expression": "self._line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 161, + "expression": "self._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 971, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 972, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1178, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1208, + "expression": "node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1315, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1491, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1498, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1503, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1512, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 513, + "expression": "self._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 972, + "expression": "node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 989, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 995, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1003, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1009, + "expression": "Tree.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1234, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1250, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1265, + "expression": "node._line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1293, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1473, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1474, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1476, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1477, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1481, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1482, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1484, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1485, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1489, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1490, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1492, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1496, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1497, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1499, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1504, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1513, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1532, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1601, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 845, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 974, + "expression": "self.scroll_to_node", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1029, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1106, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1126, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1295, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1295, + "expression": "self.cursor_node._line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1296, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1297, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1424, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1518, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1518, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1535, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1535, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1546, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1552, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 992, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1061, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1464, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1560, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1562, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1574, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1572, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 976, + "expression": "self.cursor_line", + "context": "Load" + } + ] + }, + { + "repo": "/home/ts/wt/toad-regression-widget-cost-20261001", + "head": "f5e4d6a5420cf07f37a303134c8722580820c024", + "modules": 288, + "omissions": [], + "sites": [ + { + "path": "src/toad/ansi/_ansi.py", + "line": 468, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 553, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1230, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 282, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 284, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 397, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 417, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 471, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 483, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 552, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 555, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 616, + "expression": "state.buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1144, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1146, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1190, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1500, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1513, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1546, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 404, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 463, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 466, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 470, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 474, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 476, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 478, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 480, + "expression": "buffer.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 481, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 487, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 673, + "expression": "state.buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1185, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1564, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 472, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/ansi/_ansi.py", + "line": 1566, + "expression": "buffer.cursor_line", + "context": "Load" + }, + { + "path": "src/toad/widgets/context_explorer.py", + "line": 153, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/toad/widgets/conversation.py", + "line": 2059, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/toad/widgets/conversation.py", + "line": 2062, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/toad/widgets/patch_diff.py", + "line": 243, + "expression": "self.line", + "context": "Store" + }, + { + "path": "src/toad/widgets/patch_diff.py", + "line": 247, + "expression": "self.line", + "context": "Load" + }, + { + "path": "src/toad/widgets/patch_diff.py", + "line": 331, + "expression": "row.line", + "context": "Load" + }, + { + "path": "src/toad/widgets/project_directory_tree.py", + "line": 81, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/widgets/project_tree_intent.py", + "line": 56, + "expression": "tree.cursor_line", + "context": "Store" + }, + { + "path": "src/toad/widgets/project_tree_intent.py", + "line": 56, + "expression": "selection.line", + "context": "Load" + }, + { + "path": "src/toad/widgets/prompt.py", + "line": 262, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/toad/widgets/prompt.py", + "line": 355, + "expression": "self.move_cursor", + "context": "Load" + }, + { + "path": "src/toad/widgets/terminal.py", + "line": 312, + "expression": "buffer.cursor_line", + "context": "Load" + } + ] + } +] diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index da2a2175b63..25198b5cd27 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -271,10 +271,9 @@ async def _reload(self, node: TreeNode[DirEntry]) -> None: # Track node that was highlighted before reloading. highlighted_path: None | Path = None - if self.cursor_line > -1: - highlighted_node = self.get_node_at_line(self.cursor_line) - if highlighted_node is not None and highlighted_node.data is not None: - highlighted_path = highlighted_node.data.path + highlighted_node = self.cursor_node + if highlighted_node is not None and highlighted_node.data is not None: + highlighted_path = highlighted_node.data.path if node.data is not None: self.reset_node( diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 83aba33d963..0be98e0e2b4 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -158,6 +158,7 @@ def siblings(self) -> TreeNodes[TreeDataType]: @property def line(self) -> int: """The line number for this node, or -1 if it is not displayed.""" + self._tree._tree_lines return self._line @property @@ -968,6 +969,7 @@ def move_cursor( node: A tree node, or None to reset cursor. animate: Enable animation """ + self._tree_lines previous_cursor_line = self.cursor_line self.cursor_line = -1 if node is None else node._line if node is not None and self.cursor_node is not None: @@ -986,13 +988,12 @@ def move_cursor_to_line(self, line: int, animate=False) -> None: Raises: IndexError: If the line doesn't exist. """ - if self.cursor_line == line: - return try: node = self._tree_lines[line].node except IndexError: raise IndexError(f"No line no. {line} in the tree") - self.move_cursor(node, animate=animate) + if self.cursor_line != line: + self.move_cursor(node, animate=animate) def select_node(self, node: TreeNode[TreeDataType] | None) -> None: """Move the cursor to the given node and select it, or reset cursor. @@ -1205,6 +1206,7 @@ def scroll_to_node( node: Node to scroll into view. animate: Animate scrolling. """ + self._tree_lines line = node._line if line != -1: self.scroll_to_line(line, animate=animate) @@ -1470,22 +1472,20 @@ def notify_style_update(self) -> None: def action_cursor_up(self) -> None: """Move the cursor up one node.""" - if self.cursor_line == -1: - self.cursor_line = self.last_line - else: - self.cursor_line -= 1 + last_line = self.last_line + line = self.cursor_line + self.cursor_line = last_line if line == -1 else line - 1 self.scroll_to_line(self.cursor_line, animate=False) def action_cursor_down(self) -> None: """Move the cursor down one node.""" - if self.cursor_line == -1: - self.cursor_line = 0 - else: - self.cursor_line += 1 + self._tree_lines + self.cursor_line = max(0, self.cursor_line + 1) self.scroll_to_line(self.cursor_line, animate=False) def action_page_down(self) -> None: """Move the cursor down a page's-worth of nodes.""" + self._tree_lines if self.cursor_line == -1: self.cursor_line = 0 self.cursor_line += self.scrollable_content_region.height - 1 @@ -1493,8 +1493,9 @@ def action_page_down(self) -> None: def action_page_up(self) -> None: """Move the cursor up a page's-worth of nodes.""" + last_line = self.last_line if self.cursor_line == -1: - self.cursor_line = self.last_line + self.cursor_line = last_line self.cursor_line -= self.scrollable_content_region.height - 1 self.scroll_to_line(self.cursor_line) diff --git a/tests/tree/test_tree_cursor.py b/tests/tree/test_tree_cursor.py index ada1a41c575..03193d2e42c 100644 --- a/tests/tree/test_tree_cursor.py +++ b/tests/tree/test_tree_cursor.py @@ -2,6 +2,8 @@ from typing import Any +import pytest + from textual import on from textual.app import App, ComposeResult from textual.widgets import Tree @@ -100,3 +102,105 @@ async def test_select_node_reset() -> None: ("NodeHighlighted", 1), # From the `tree.move_cursor` call ("NodeHighlighted", 0), # From the call to `tree.select_node(None)` ] + + +@pytest.mark.parametrize("operation", ["move", "select", "line", "scroll"]) +async def test_unbuilt_offscreen_node(operation: str) -> None: + """A mounted source mutation precedes rendering; intent must use its new line.""" + async with TreeApp().run_test(size=(40, 8)) as pilot: + tree = pilot.app.query_one(Tree) + for index in range(30): + target = tree.root.add_leaf(str(index)) + assert target._line == -1 + assert tree._tree_lines_cached is None + if operation == "move": + tree.move_cursor(target) + elif operation == "select": + tree.select_node(target) + elif operation == "line": + tree.cursor_line = target.line + else: + tree.scroll_to_node(target, animate=False) + assert target._line == 31 + if operation != "scroll": + assert tree.cursor_node is target + await pilot.pause() + if operation != "line": + assert tree.scroll_y > 0 + assert pilot.app._exception is None + + +@pytest.mark.parametrize("action", ["cursor_up", "cursor_down", "page_up", "page_down"]) +async def test_navigation_after_lazy_line_rebase(action: str) -> None: + """Navigation starts at the selected node's rebuilt line, not its old ordinal.""" + async with TreeApp().run_test(size=(40, 8)) as pilot: + tree = pilot.app.query_one(Tree) + nodes = [tree.root.add_leaf(str(index)) for index in range(30)] + tree.move_cursor(nodes[15]) + old_line = tree.cursor_line + tree.root.add_leaf("inserted", before=nodes[0]) + assert tree._tree_lines_cached is None + step = tree.scrollable_content_region.height - 1 + offset = { + "cursor_up": -1, + "cursor_down": 1, + "page_up": -step, + "page_down": step, + }[action] + getattr(tree, "action_" + action)() + assert tree.cursor_line == old_line + 1 + offset + assert tree.cursor_node is nodes[15 + offset] + assert pilot.app._exception is None + + +async def test_move_to_line_after_lazy_rebase() -> None: + """The equality shortcut must compare the new projection's cursor ordinal.""" + async with TreeApp().run_test() as pilot: + tree = pilot.app.query_one(Tree) + target = pilot.app.node + tree.move_cursor(target) + previous_line = tree.cursor_line + inserted = tree.root.add_leaf("inserted", before=target) + tree.move_cursor_to_line(previous_line) + assert tree.cursor_node is inserted + assert pilot.app._exception is None + + +async def test_remount_restores_original_node_data() -> None: + """Replacing the native Tree preserves external selection without a frame wait.""" + async with TreeApp().run_test() as pilot: + tree = pilot.app.query_one(Tree) + original_data = object() + await tree.remove() + replacement = Tree("replacement") + target = replacement.root.add_leaf("selected", data=original_data) + replacement.root.expand() + await pilot.app.screen.mount(replacement) + replacement.root.add_leaf("new source") + replacement.move_cursor(target) + assert replacement.cursor_node is target + assert replacement.cursor_node.data is original_data + assert replacement.get_node_at_line(replacement.cursor_line) is target + assert pilot.app._exception is None + + +async def test_hidden_and_foreign_node_contract() -> None: + """Projection acquisition does not reveal branches or acquire another Tree.""" + async with TreeApp().run_test() as pilot: + tree = pilot.app.query_one(Tree) + branch = tree.root.add("closed") + hidden = branch.add_leaf("hidden") + tree.move_cursor(hidden) + assert tree.cursor_node is tree.root + assert branch.is_collapsed + assert hidden._line == -1 + unrelated = Tree("unrelated") + foreign = unrelated.root.add_leaf("foreign") + tree.move_cursor(foreign) + assert tree.cursor_node is tree.root + assert unrelated._tree_lines_cached is None + tree.move_cursor(None) + assert tree.cursor_node is tree.root + tree.unselect() + assert tree.cursor_line == -1 + assert tree.cursor_node is tree.root