From 11a02db2c34a08915a4839460a6658cdaa73b992 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 04:33:38 -0400 Subject: [PATCH 1/4] Scope original Tree cursor projection ownership --- .../tree-cursor-projection-20261004/SCOPE.md | 9 + .../before-consumers.json | 918 ++++++++++++++++++ 2 files changed, 927 insertions(+) create mode 100644 evidence/tree-cursor-projection-20261004/SCOPE.md create mode 100644 evidence/tree-cursor-projection-20261004/before-consumers.json diff --git a/evidence/tree-cursor-projection-20261004/SCOPE.md b/evidence/tree-cursor-projection-20261004/SCOPE.md new file mode 100644 index 0000000000..c05cc7e78c --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/SCOPE.md @@ -0,0 +1,9 @@ +# 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. 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 0000000000..e01e6321cc --- /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" + } + ] + } +] From 5007e2786b7d61c22be3816449d90b31e5ad6192 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 04:37:36 -0400 Subject: [PATCH 2/4] Acquire Tree projection before cursor intent and navigation --- src/textual/widgets/_directory_tree.py | 7 +- src/textual/widgets/_tree.py | 24 +++---- tests/tree/test_tree_cursor.py | 99 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index da2a2175b6..25198b5cd2 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 83aba33d96..3db3e29170 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,19 @@ 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 + self.cursor_line = last_line if self.cursor_line == -1 else self.cursor_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 +1492,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 ada1a41c57..647b333266 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,100 @@ 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 None From d0c11d918f59fbf9e71d1fb0fdb67975421479a2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 04:39:59 -0400 Subject: [PATCH 3/4] Preserve original unselect contract and record native cursor qualification --- .../NATIVE-APP-QUALIFIED.json | 24 + .../tree-cursor-projection-20261004/SCOPE.md | 16 + .../after-consumers.json | 681 ++++++++++++++++++ src/textual/widgets/_tree.py | 3 +- tests/tree/test_tree_cursor.py | 9 +- 5 files changed, 730 insertions(+), 3 deletions(-) create mode 100644 evidence/tree-cursor-projection-20261004/NATIVE-APP-QUALIFIED.json create mode 100644 evidence/tree-cursor-projection-20261004/after-consumers.json 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 0000000000..9d5b013ee9 --- /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 index c05cc7e78c..9efa7cc4fe 100644 --- a/evidence/tree-cursor-projection-20261004/SCOPE.md +++ b/evidence/tree-cursor-projection-20261004/SCOPE.md @@ -7,3 +7,19 @@ Acquire the original projection before reading node or current cursor lines. Clo 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 0000000000..8b10e7445f --- /dev/null +++ b/evidence/tree-cursor-projection-20261004/after-consumers.json @@ -0,0 +1,681 @@ +{ + "head": "5007e2786b7d61c22be3816449d90b31e5ad6192", + "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": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1481, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1482, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1487, + "expression": "self._tree_lines", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1490, + "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": 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": 1477, + "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": 1488, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1489, + "expression": "self.cursor_line", + "context": "Store" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1491, + "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": 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": 1476, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1476, + "expression": "self.cursor_line", + "context": "Load" + }, + { + "path": "src/textual/widgets/_tree.py", + "line": 1482, + "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": 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": 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": 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/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 3db3e29170..0be98e0e2b 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1473,7 +1473,8 @@ def notify_style_update(self) -> None: def action_cursor_up(self) -> None: """Move the cursor up one node.""" last_line = self.last_line - self.cursor_line = last_line if self.cursor_line == -1 else self.cursor_line - 1 + 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: diff --git a/tests/tree/test_tree_cursor.py b/tests/tree/test_tree_cursor.py index 647b333266..03193d2e42 100644 --- a/tests/tree/test_tree_cursor.py +++ b/tests/tree/test_tree_cursor.py @@ -141,7 +141,12 @@ async def test_navigation_after_lazy_line_rebase(action: str) -> None: 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] + 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] @@ -198,4 +203,4 @@ async def test_hidden_and_foreign_node_contract() -> None: assert tree.cursor_node is tree.root tree.unselect() assert tree.cursor_line == -1 - assert tree.cursor_node is None + assert tree.cursor_node is tree.root From 0bc23a0741dacfdaca5c6043d453c46bbfa4e1c2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 4 Oct 2026 04:41:04 -0400 Subject: [PATCH 4/4] Publish frozen Tree source and standalone wheel handoff --- .../FILEWHEEL.json | 42 ++++++++++ .../after-consumers.json | 80 +++++++++---------- 2 files changed, 79 insertions(+), 43 deletions(-) create mode 100644 evidence/tree-cursor-projection-20261004/FILEWHEEL.json diff --git a/evidence/tree-cursor-projection-20261004/FILEWHEEL.json b/evidence/tree-cursor-projection-20261004/FILEWHEEL.json new file mode 100644 index 0000000000..d153bfda5a --- /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/after-consumers.json b/evidence/tree-cursor-projection-20261004/after-consumers.json index 8b10e7445f..d3769b04de 100644 --- a/evidence/tree-cursor-projection-20261004/after-consumers.json +++ b/evidence/tree-cursor-projection-20261004/after-consumers.json @@ -1,5 +1,5 @@ { - "head": "5007e2786b7d61c22be3816449d90b31e5ad6192", + "head": "d0c11d918f59fbf9e71d1fb0fdb67975421479a2", "modules": 249, "omissions": [], "sites": [ @@ -307,47 +307,53 @@ "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": 1481, + "line": 1482, "expression": "self._tree_lines", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1482, + "line": 1483, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1487, + "line": 1488, "expression": "self._tree_lines", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1490, + "line": 1491, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1498, + "line": 1499, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1503, + "line": 1504, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1512, + "line": 1513, "expression": "self.cursor_line", "context": "Store" }, @@ -407,73 +413,73 @@ }, { "path": "src/textual/widgets/_tree.py", - "line": 1477, + "line": 1478, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1483, + "line": 1484, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1488, + "line": 1489, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1489, + "line": 1490, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1491, + "line": 1492, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1496, + "line": 1497, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1497, + "line": 1498, "expression": "self.cursor_line", "context": "Store" }, { "path": "src/textual/widgets/_tree.py", - "line": 1499, + "line": 1500, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1504, + "line": 1505, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1513, + "line": 1514, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1532, + "line": 1533, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1601, + "line": 1602, "expression": "self.move_cursor", "context": "Load" }, @@ -545,55 +551,43 @@ }, { "path": "src/textual/widgets/_tree.py", - "line": 1476, - "expression": "self.cursor_line", - "context": "Load" - }, - { - "path": "src/textual/widgets/_tree.py", - "line": 1476, - "expression": "self.cursor_line", - "context": "Load" - }, - { - "path": "src/textual/widgets/_tree.py", - "line": 1482, + "line": 1483, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1518, + "line": 1519, "expression": "self._tree_lines", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1518, + "line": 1519, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1535, + "line": 1536, "expression": "self._tree_lines", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1535, + "line": 1536, "expression": "self.cursor_line", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1546, + "line": 1547, "expression": "self.move_cursor", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1552, + "line": 1553, "expression": "self.move_cursor", "context": "Load" }, @@ -617,25 +611,25 @@ }, { "path": "src/textual/widgets/_tree.py", - "line": 1560, + "line": 1561, "expression": "self.move_cursor", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1562, + "line": 1563, "expression": "self.move_cursor", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1574, + "line": 1575, "expression": "self.move_cursor", "context": "Load" }, { "path": "src/textual/widgets/_tree.py", - "line": 1572, + "line": 1573, "expression": "self.move_cursor", "context": "Load" },