From 36e245a55ce672c7746a9c50ec42b7a4312f68c9 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 16:25:12 -0400 Subject: [PATCH 1/2] Make Screen selection borrow its original primary press lifetime --- .../README.md | 9 + .../before.json | 593 ++++++++++++++++++ src/textual/screen.py | 30 +- 3 files changed, 620 insertions(+), 12 deletions(-) create mode 100644 evidence/primary-selection-gesture-20261006/README.md create mode 100644 evidence/primary-selection-gesture-20261006/before.json diff --git a/evidence/primary-selection-gesture-20261006/README.md b/evidence/primary-selection-gesture-20261006/README.md new file mode 100644 index 0000000000..e26e9fa9b0 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/README.md @@ -0,0 +1,9 @@ +# Primary Screen selection gesture + +Screen alone admits its pointer selection range on a primary press and ends it on a primary release. Secondary input continues through the original recipient/capture/focus/menu route; it cannot start or end Screen's primary gesture or emit its TextSelected boundary. + +The existing primary press offset and pointer range now supply _selecting. Deleted its separate reactive value and writes, including the range-projection assignment which could revive a released gesture. The original press/range watchers stop the existing auto-scroll timer when either custody or range retires; the timer callback borrows the same active gesture. The range remains available after release for copy and layout projection. + +Input/TextArea own separate editor selection and capture models; this change concerns Screen's selectable-content range. Toad/visual consumers read _selecting and need no alternate state or API. before.json uses the existing Package AST across native production/tests and read-only Toad production/tests/tools; dynamic external subclasses remain unresolved. + +Source checkpoint. Affected real source Apps and native selection/copy/retirement checks are pending. This is a source counterexample; no sole physical/menu causal claim. Frozen a59/2d wheels and the prepared menu02 App remain unchanged. No package, installed pin, provider or physical operation. diff --git a/evidence/primary-selection-gesture-20261006/before.json b/evidence/primary-selection-gesture-20261006/before.json new file mode 100644 index 0000000000..6c13234d2a --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/before.json @@ -0,0 +1,593 @@ +{ + "coverage": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "cb95661ee0b02d914fef9290658fed85b8f367c2", + "root": "src/textual", + "parsed": 249, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "cb95661ee0b02d914fef9290658fed85b8f367c2", + "root": "tests", + "parsed": 466, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "src/toad", + "parsed": 288, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "tests", + "parsed": 403, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "tools", + "parsed": 40, + "omissions": [] + } + ], + "sites": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/app.py", + "references": { + "clear_selection:FunctionDef:NoneType": [ + 4623 + ], + "clear_selection:Attribute:Load": [ + 4626 + ], + "_forward_event:Attribute:Load": [ + 4133, + 4191, + 4195, + 4197, + 4189, + 4167 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/events.py", + "references": { + "TextSelected:ClassDef:NoneType": [ + 998 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/screen.py", + "references": { + "clear_selection:FunctionDef:NoneType": [ + 768 + ], + "_retire_selection:FunctionDef:NoneType": [ + 1547 + ], + "_start_auto_scroll:FunctionDef:NoneType": [ + 1886 + ], + "_stop_auto_scroll:FunctionDef:NoneType": [ + 1937 + ], + "_check_auto_scroll:FunctionDef:NoneType": [ + 1943 + ], + "_update_select:FunctionDef:NoneType": [ + 2004 + ], + "_forward_event:FunctionDef:NoneType": [ + 2008 + ], + "_watch__selecting:FunctionDef:NoneType": [ + 2153 + ], + "_watch__select_state:FunctionDef:NoneType": [ + 2166 + ], + "_flush_pending_selection:FunctionDef:NoneType": [ + 2198 + ], + "_apply_selection_state:FunctionDef:NoneType": [ + 2204 + ], + "_selecting:Name:Store": [ + 253 + ], + "_select_state:Name:Store": [ + 259 + ], + "_mouse_down_offset:Name:Store": [ + 262 + ], + "_auto_select_scroll_timer:Attribute:Store": [ + 332, + 1933, + 1941 + ], + "_selecting:Attribute:Load": [ + 599, + 2021 + ], + "_select_state:Attribute:Store": [ + 771, + 1558, + 2216, + 2040, + 2101, + 2113 + ], + "_selecting:Attribute:Store": [ + 2206, + 2073 + ], + "SelectState:Name:Load": [ + 259, + 2166, + 2204, + 2101 + ], + "_flush_pending_selection:Attribute:Load": [ + 983, + 1292, + 2057 + ], + "_retire_selection:Attribute:Load": [ + 1544 + ], + "_stop_auto_scroll:Attribute:Load": [ + 1921, + 2002, + 2155, + 2052 + ], + "_auto_select_scroll_timer:Attribute:Load": [ + 1939, + 1963, + 1940 + ], + "_watch__select_state:Attribute:Load": [ + 2006 + ], + "_select_state:Attribute:Load": [ + 2006, + 2160, + 1555, + 1908, + 2202, + 1556, + 2021, + 2161, + 2040 + ], + "clear_selection:Attribute:Load": [ + 2151, + 2070 + ], + "_apply_selection_state:Attribute:Load": [ + 2196, + 2202 + ], + "_forward_event:Attribute:Load": [ + 1840, + 2141 + ], + "_update_select:Attribute:Load": [ + 1918 + ], + "_mouse_down_offset:Attribute:Store": [ + 2072, + 2077 + ], + "_start_auto_scroll:Attribute:Load": [ + 1991, + 1999 + ], + "_check_auto_scroll:Attribute:Load": [ + 2046 + ], + "_mouse_down_offset:Attribute:Load": [ + 2059, + 2060 + ], + "TextSelected:Attribute:Load": [ + 2074 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/scrollbar.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 352 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 356 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 378 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/selection.py", + "references": { + "SelectState:ClassDef:NoneType": [ + 161 + ], + "SelectState:Name:Load": [ + 283, + 294 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/visual.py", + "references": { + "_selecting:Attribute:Load": [ + 241 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widget.py", + "references": { + "_forward_event:FunctionDef:NoneType": [ + 4550 + ], + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 4971 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 4974 + ], + "_retire_selection:Attribute:Load": [ + 1804 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_data_table.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 2555 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_input.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 786 + ], + "_end_selecting:FunctionDef:NoneType": [ + 793 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 803 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 806 + ], + "_selecting:Attribute:Store": [ + 454, + 790, + 796 + ], + "_selecting:Attribute:Load": [ + 795, + 807 + ], + "clear_selection:Attribute:Load": [ + 518 + ], + "_end_selecting:Attribute:Load": [ + 801, + 804 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_option_list.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 740 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_text_area.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 1997 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 2008 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 2023 + ], + "_selecting:Attribute:Store": [ + 674, + 2001, + 2018 + ], + "_selecting:Attribute:Load": [ + 2010, + 2017 + ], + "clear_selection:Attribute:Load": [ + 894 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_tree.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 1082 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_pointer_capture_order.py", + "references": { + "_selecting:Attribute:Load": [ + 139 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_removed_scene_ownership.py", + "references": { + "_select_state:Attribute:Load": [ + 71, + 67, + 78 + ], + "_flush_pending_selection:Attribute:Load": [ + 83 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_selection.py", + "references": { + "_select_state:Attribute:Load": [ + 197, + 216, + 264, + 403, + 484 + ], + "_select_state:Attribute:Store": [ + 488, + 267 + ], + "clear_selection:Attribute:Load": [ + 207, + 490, + 269 + ], + "SelectState:Name:Load": [ + 201 + ], + "_forward_event:Attribute:Load": [ + 254, + 259 + ], + "_apply_selection_state:Attribute:Load": [ + 470 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/app.py", + "references": { + "TextSelected:Attribute:Load": [ + 592 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/conversation.py", + "references": { + "_mouse_down_offset:Attribute:Store": [ + 476, + 1823 + ], + "clear_selection:Attribute:Load": [ + 681 + ], + "_mouse_down_offset:Attribute:Load": [ + 1834, + 1835 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/patch_diff.py", + "references": { + "_selecting:Attribute:Load": [ + 327 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/selection.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 44 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/history_mounted_budget_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 72 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/off_tail_checkpoint_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 148 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/projected_history_budget_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 96 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/retained_tool_text_pilot.py", + "references": { + "_select_state:Attribute:Load": [ + 94, + 103 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/scroll_select_latency_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 65, + 209, + 317 + ], + "_select_state:Attribute:Load": [ + 215 + ], + "SelectState:Name:Load": [ + 301, + 308 + ], + "_stop_auto_scroll:Attribute:Load": [ + 323 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/selection_hover_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 32, + 39 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/sidebar_drag_resize_pilot.py", + "references": { + "_selecting:Attribute:Load": [ + 61, + 63, + 78 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/sidebar_edge_anchor_installed_pilot.py", + "references": { + "_selecting:Attribute:Load": [ + 79, + 81 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/stale_selection_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 35 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/transcript_source_lifetime_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 71 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/viewport_body_lifetime_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 152 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/viewport_selection_diff_pilot.py", + "references": { + "_select_state:Attribute:Load": [ + 55 + ], + "_forward_event:Attribute:Load": [ + 50, + 51, + 79 + ], + "_stop_auto_scroll:Attribute:Load": [ + 53 + ], + "_update_select:Attribute:Load": [ + 74 + ], + "SelectState:Name:Load": [ + 63 + ] + } + } + ], + "limitations": "Lexical sites include distinct Input/TextArea selection owners. External subclasses/dynamic calls are unresolved; no parse omissions are treated as evidence of absence." +} diff --git a/src/textual/screen.py b/src/textual/screen.py index 02097cec0a..b7d704109d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -250,17 +250,14 @@ class Screen(Generic[ScreenResultType], Widget): selections: var[dict[Widget, Selection]] = var(dict) """Map of widgets and selected ranges.""" - _selecting = var(False) - """Indicates mouse selection is in progress.""" - # Selection paints through the changed widgets' own refreshes. Repainting # the entire screen whenever the pointer moves makes transcript drags # scale with every mounted history pane and forces a full compositor pass. _select_state: Reactive[SelectState | None] = Reactive(None, repaint=False) - """Current select state, if selecting.""" + """Pointer range, retained after release for selection projection and copy.""" _mouse_down_offset: var[Offset | None] = var(None) - """Last mouse down screen offset, or `None` if the mouse is up.""" + """Primary press screen offset, or `None` after its release.""" _pointer_shape: var[PointerShape] = var("default") """The current mouse pointer shape.""" @@ -580,6 +577,11 @@ def allow_select(self) -> bool: """Check if this widget permits text selection.""" return self.ALLOW_SELECT + @property + def _selecting(self) -> bool: + """Whether the original primary press still owns a pointer range.""" + return self._mouse_down_offset is not None and self._select_state is not None + def get_loading_widget(self) -> Widget: """Get a widget to display a loading indicator. @@ -1905,7 +1907,7 @@ def _auto_scroll_y(widget: Widget, direction: float) -> None: widget: Container widgets to scroll. direction: Lines to scroll. """ - if self._select_state is not None: + if self._selecting: # Update scroll position previous_offset = widget.scroll_offset widget.scroll_y += direction @@ -2053,7 +2055,7 @@ def _forward_event(self, event: events.Event) -> AwaitComplete: elif isinstance(event, events.MouseEvent): event.widget = None - if isinstance(event, events.MouseUp): + if isinstance(event, events.MouseUp) and event.button == 1: self._flush_pending_selection() if ( self._mouse_down_offset is not None @@ -2070,10 +2072,13 @@ def _forward_event(self, event: events.Event) -> AwaitComplete: self.clear_selection() self._mouse_down_offset = None - self._selecting = False self.post_message(events.TextSelected()) - elif isinstance(event, events.MouseDown) and not self.app.mouse_captured: + elif ( + isinstance(event, events.MouseDown) + and event.button == 1 + and not self.app.mouse_captured + ): self._mouse_down_offset = event.screen_offset select_widget, select_offset = self.get_widget_and_offset_at( event.x, event.y @@ -2150,8 +2155,8 @@ def _forward_event(self, event: events.Event) -> AwaitComplete: def _key_escape(self) -> None: self.clear_selection() - def _watch__selecting(self, selecting: bool) -> None: - if not selecting: + def _watch__mouse_down_offset(self, offset: Offset | None) -> None: + if offset is None: self._stop_auto_scroll() def _interaction_widgets(self) -> Iterator[Widget]: @@ -2169,6 +2174,8 @@ def _watch__select_state(self, select_state: SelectState | None) -> None: Args: select_state: Current selection state. """ + if select_state is None: + self._stop_auto_scroll() if ( select_state is not None and select_state.end is not None @@ -2203,7 +2210,6 @@ def _flush_pending_selection(self) -> None: def _apply_selection_state(self, select_state: SelectState | None) -> None: """Calculate widget selections from the current pointer state.""" - self._selecting = select_state is not None if select_state is None: return From 21d108c8bae0a436dd9708f1ec673692ddf66a7f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 16:37:07 -0400 Subject: [PATCH 2/2] Qualify primary selection, secondary menu delivery and Screen retirement --- .../QUALIFIED.json | 23 + .../README.md | 15 +- .../after.json | 664 ++++++++++++++++++ .../before.json | 24 + .../changed-family.log | 20 + .../run-async-final.log | 2 + .../suspension.log | 2 + src/textual/screen.py | 3 + tests/test_primary_selection_gesture.py | 203 ++++++ 9 files changed, 954 insertions(+), 2 deletions(-) create mode 100644 evidence/primary-selection-gesture-20261006/QUALIFIED.json create mode 100644 evidence/primary-selection-gesture-20261006/after.json create mode 100644 evidence/primary-selection-gesture-20261006/changed-family.log create mode 100644 evidence/primary-selection-gesture-20261006/run-async-final.log create mode 100644 evidence/primary-selection-gesture-20261006/suspension.log create mode 100644 tests/test_primary_selection_gesture.py diff --git a/evidence/primary-selection-gesture-20261006/QUALIFIED.json b/evidence/primary-selection-gesture-20261006/QUALIFIED.json new file mode 100644 index 0000000000..e418c9fd63 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/QUALIFIED.json @@ -0,0 +1,23 @@ +{ + "scope": "Primary Screen selection lifecycle and original auto-scroll, with real native source Apps/driver/menu. No sole physical/menu cause, installed Toad or latency claim.", + "source_sha256": { + "src/textual/screen.py": "b02f5b4849bbcc8ef911c727e66391fe8593c993a0aacdd31adf5059e7b424be", + "tests/test_primary_selection_gesture.py": "6f7985f2f3ac83b1519bc5d551435ea58c52ebfefa8fb5dba87c2724c2d88685" + }, + "keeper_sha256": { + "before.json": "3541646b10b31f7393a22089b3a3a43733b94f9bf1f2f26a6b5995746a29102f", + "after.json": "5ee17648efcc8af82f65032c65301a02ddaf0c85a10e7b7d706d477f528e215b", + "changed-family.log": "a36a608e53a572ab92e218dafd903d58449b3c3809ea2a4afa80236961204da4", + "run-async-final.log": "070a0a1a384f8986bf91c5e6a28d1adb0cf8744a853fe1ce7e8ca4541698f8d4", + "suspension.log": "808dfae1ed8f9fdc74e04e9f4daf48a799b85ea62a74ce685b5f7c7368c58182" + }, + "checks": { + "initial_batch": "24 passed, 1 authored startup fixture failure in 26.72s", + "corrected_original_run_async": "1 passed in 1.21s", + "changed_screen_suspension": "1 passed in 1.19s" + }, + "preserved": "Frozen a59/2d wheels, menu02 prepared attempt and originals unchanged.", + "provider_inputs": 0, + "installed_changes": 0, + "physical_runs": 0 +} diff --git a/evidence/primary-selection-gesture-20261006/README.md b/evidence/primary-selection-gesture-20261006/README.md index e26e9fa9b0..0153dd1dbc 100644 --- a/evidence/primary-selection-gesture-20261006/README.md +++ b/evidence/primary-selection-gesture-20261006/README.md @@ -2,8 +2,19 @@ Screen alone admits its pointer selection range on a primary press and ends it on a primary release. Secondary input continues through the original recipient/capture/focus/menu route; it cannot start or end Screen's primary gesture or emit its TextSelected boundary. -The existing primary press offset and pointer range now supply _selecting. Deleted its separate reactive value and writes, including the range-projection assignment which could revive a released gesture. The original press/range watchers stop the existing auto-scroll timer when either custody or range retires; the timer callback borrows the same active gesture. The range remains available after release for copy and layout projection. +The existing primary press offset and pointer range now supply _selecting. Deleted its separate reactive value and writes, including the range-projection assignment which could revive a released gesture. The original press/range watchers stop the existing auto-scroll timer when either custody or range retires; the timer callback borrows the same active gesture. The range remains available after release for copy and layout projection. Screen suspension retires the press too: its release will route to another Screen, so the covered Screen cannot retain auto-scroll custody. Copyable ranges and pending projections remain intact. Input/TextArea own separate editor selection and capture models; this change concerns Screen's selectable-content range. Toad/visual consumers read _selecting and need no alternate state or API. before.json uses the existing Package AST across native production/tests and read-only Toad production/tests/tools; dynamic external subclasses remain unresolved. -Source checkpoint. Affected real source Apps and native selection/copy/retirement checks are pending. This is a source counterexample; no sole physical/menu causal claim. Frozen a59/2d wheels and the prepared menu02 App remain unchanged. No package, installed pin, provider or physical operation. +Qualified source App scope: + +- A secondary edge drag acquires neither a selection nor its auto-scroll timer. +- Secondary release during a primary drag preserves its range, primary press and completion ownership. +- Actual run_async/HeadlessDriver primary edge movement scrolls through the existing timer; primary release stops it and range projection cannot revive the gesture. +- Right capture/release and a real pushed menu deliver normally while preserving completed primary text. +- Screen suspension stops active primary scrolling; returning retains copyable text without resurrecting the press. +- Existing selection/copy/coalescence and endpoint-retirement checks pass. + +Initial batch: 24 passed, one authored run_async fixture failed (26.72s). Source shows its mounted callback preceded resized arrangement; the fixture tried to scroll before its original max_scroll_y was available, so the requested row was not its actual press target. The corrected fixture borrows the existing publication before scrolling and asserts scroll/recipient identity: that exact control passes (1.21s). The separate changed Screen-suspension control passes (1.19s). The production press/move/release correction was unchanged by the fixture repair. Both logs remain preserved. No passing controls were repeated for count. + +before.json/after.json: 249 native modules, 466 -> 467 tests, dependency Toad 288 production/403 tests/40 tools, zero omissions. Screen/visual/Toad readers derive activity through the same property; no writer or reactive subscription to Screen._selecting was found. Toad suspension consumers keep their original base behavior. Input/TextArea names are distinct owners, not competing Screen facts. This is a source counterexample; no sole physical/menu causal claim. Frozen a59/2d wheels and the prepared menu02 App remain unchanged. No package, installed pin, provider or physical operation. diff --git a/evidence/primary-selection-gesture-20261006/after.json b/evidence/primary-selection-gesture-20261006/after.json new file mode 100644 index 0000000000..729cdd6341 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/after.json @@ -0,0 +1,664 @@ +{ + "coverage": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "6934a31001b65e4c6b76583d43621d23de5118ae", + "root": "src/textual", + "parsed": 249, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "revision": "6934a31001b65e4c6b76583d43621d23de5118ae", + "root": "tests", + "parsed": 467, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "src/toad", + "parsed": 288, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "tests", + "parsed": 403, + "omissions": [] + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "revision": "167a086731ec13736ee25e37de39685aa5a2da20", + "root": "tools", + "parsed": 40, + "omissions": [] + } + ], + "sites": [ + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/app.py", + "references": { + "clear_selection:FunctionDef:NoneType": [ + 4623 + ], + "clear_selection:Attribute:Load": [ + 4626 + ], + "_forward_event:Attribute:Load": [ + 4133, + 4191, + 4195, + 4197, + 4189, + 4167 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/events.py", + "references": { + "TextSelected:ClassDef:NoneType": [ + 998 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/screen.py", + "references": { + "_selecting:FunctionDef:NoneType": [ + 581 + ], + "clear_selection:FunctionDef:NoneType": [ + 770 + ], + "_retire_selection:FunctionDef:NoneType": [ + 1549 + ], + "_on_screen_suspend:FunctionDef:NoneType": [ + 1639 + ], + "_start_auto_scroll:FunctionDef:NoneType": [ + 1891 + ], + "_stop_auto_scroll:FunctionDef:NoneType": [ + 1942 + ], + "_check_auto_scroll:FunctionDef:NoneType": [ + 1948 + ], + "_update_select:FunctionDef:NoneType": [ + 2009 + ], + "_forward_event:FunctionDef:NoneType": [ + 2013 + ], + "_watch__mouse_down_offset:FunctionDef:NoneType": [ + 2161 + ], + "_watch__select_state:FunctionDef:NoneType": [ + 2174 + ], + "_flush_pending_selection:FunctionDef:NoneType": [ + 2208 + ], + "_apply_selection_state:FunctionDef:NoneType": [ + 2214 + ], + "_select_state:Name:Store": [ + 256 + ], + "_mouse_down_offset:Name:Store": [ + 259 + ], + "_auto_select_scroll_timer:Attribute:Store": [ + 329, + 1938, + 1946 + ], + "_selecting:Attribute:Load": [ + 601, + 1913, + 2026 + ], + "_select_state:Attribute:Store": [ + 773, + 1560, + 2225, + 2045, + 2109, + 2121 + ], + "_mouse_down_offset:Attribute:Store": [ + 1643, + 2077, + 2085 + ], + "SelectState:Name:Load": [ + 256, + 2174, + 2214, + 2109 + ], + "_flush_pending_selection:Attribute:Load": [ + 985, + 1294, + 2062 + ], + "_retire_selection:Attribute:Load": [ + 1546 + ], + "_stop_auto_scroll:Attribute:Load": [ + 1926, + 2007, + 2163, + 2181, + 2057 + ], + "_auto_select_scroll_timer:Attribute:Load": [ + 1944, + 1968, + 1945 + ], + "_watch__select_state:Attribute:Load": [ + 2011 + ], + "_select_state:Attribute:Load": [ + 2011, + 2168, + 583, + 1557, + 2212, + 1558, + 2026, + 2169, + 2045 + ], + "clear_selection:Attribute:Load": [ + 2159, + 2075 + ], + "_apply_selection_state:Attribute:Load": [ + 2206, + 2212 + ], + "_mouse_down_offset:Attribute:Load": [ + 583, + 2064, + 2065 + ], + "_forward_event:Attribute:Load": [ + 1845, + 2149 + ], + "_update_select:Attribute:Load": [ + 1923 + ], + "_start_auto_scroll:Attribute:Load": [ + 1996, + 2004 + ], + "_check_auto_scroll:Attribute:Load": [ + 2051 + ], + "TextSelected:Attribute:Load": [ + 2078 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/scrollbar.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 352 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 356 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 378 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/selection.py", + "references": { + "SelectState:ClassDef:NoneType": [ + 161 + ], + "SelectState:Name:Load": [ + 283, + 294 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/visual.py", + "references": { + "_selecting:Attribute:Load": [ + 241 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widget.py", + "references": { + "_forward_event:FunctionDef:NoneType": [ + 4550 + ], + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 4971 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 4974 + ], + "_retire_selection:Attribute:Load": [ + 1804 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_data_table.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 2555 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_input.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 786 + ], + "_end_selecting:FunctionDef:NoneType": [ + 793 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 803 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 806 + ], + "_selecting:Attribute:Store": [ + 454, + 790, + 796 + ], + "_selecting:Attribute:Load": [ + 795, + 807 + ], + "clear_selection:Attribute:Load": [ + 518 + ], + "_end_selecting:Attribute:Load": [ + 801, + 804 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_option_list.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 740 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_text_area.py", + "references": { + "_on_mouse_down:AsyncFunctionDef:NoneType": [ + 1997 + ], + "_on_mouse_move:AsyncFunctionDef:NoneType": [ + 2008 + ], + "_on_mouse_up:AsyncFunctionDef:NoneType": [ + 2023 + ], + "_selecting:Attribute:Store": [ + 674, + 2001, + 2018 + ], + "_selecting:Attribute:Load": [ + 2010, + 2017 + ], + "clear_selection:Attribute:Load": [ + 894 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "src/textual/widgets/_tree.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 1082 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_pointer_capture_order.py", + "references": { + "_selecting:Attribute:Load": [ + 139 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_primary_selection_gesture.py", + "references": { + "_select_state:Attribute:Load": [ + 78, + 192, + 61, + 85, + 115, + 199 + ], + "_selecting:Attribute:Load": [ + 86, + 106, + 62, + 91, + 116, + 122, + 147, + 157, + 201 + ], + "_mouse_down_offset:Attribute:Load": [ + 60, + 84, + 92, + 146 + ], + "_auto_select_scroll_timer:Attribute:Load": [ + 63, + 107, + 117, + 123, + 143, + 148, + 159, + 202 + ], + "_update_select:Attribute:Load": [ + 119 + ], + "_flush_pending_selection:Attribute:Load": [ + 120 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_removed_scene_ownership.py", + "references": { + "_select_state:Attribute:Load": [ + 71, + 67, + 78 + ], + "_flush_pending_selection:Attribute:Load": [ + 83 + ] + } + }, + { + "checkout": "/home/ts/wt/textual-frame-publication-20261006", + "file": "tests/test_selection.py", + "references": { + "_select_state:Attribute:Load": [ + 197, + 216, + 264, + 403, + 484 + ], + "_select_state:Attribute:Store": [ + 488, + 267 + ], + "clear_selection:Attribute:Load": [ + 207, + 490, + 269 + ], + "SelectState:Name:Load": [ + 201 + ], + "_forward_event:Attribute:Load": [ + 254, + 259 + ], + "_apply_selection_state:Attribute:Load": [ + 470 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/app.py", + "references": { + "TextSelected:Attribute:Load": [ + 592 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/screens/mcp_decision.py", + "references": { + "_on_screen_suspend:FunctionDef:NoneType": [ + 66 + ], + "_on_screen_suspend:Attribute:Load": [ + 67 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/screens/workspace.py", + "references": { + "on_screen_suspend:FunctionDef:NoneType": [ + 119 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/conversation.py", + "references": { + "_mouse_down_offset:Attribute:Store": [ + 476, + 1823 + ], + "clear_selection:Attribute:Load": [ + 681 + ], + "_mouse_down_offset:Attribute:Load": [ + 1834, + 1835 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/patch_diff.py", + "references": { + "_selecting:Attribute:Load": [ + 327 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/widgets/selection.py", + "references": { + "_on_mouse_move:FunctionDef:NoneType": [ + 44 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/history_mounted_budget_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 72 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/off_tail_checkpoint_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 148 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/projected_history_budget_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 96 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/retained_tool_text_pilot.py", + "references": { + "_select_state:Attribute:Load": [ + 94, + 103 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/scroll_select_latency_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 65, + 209, + 317 + ], + "_select_state:Attribute:Load": [ + 215 + ], + "SelectState:Name:Load": [ + 301, + 308 + ], + "_stop_auto_scroll:Attribute:Load": [ + 323 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/selection_hover_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 32, + 39 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/sidebar_drag_resize_pilot.py", + "references": { + "_selecting:Attribute:Load": [ + 61, + 63, + 78 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/sidebar_edge_anchor_installed_pilot.py", + "references": { + "_selecting:Attribute:Load": [ + 79, + 81 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/stale_selection_pilot.py", + "references": { + "_forward_event:Attribute:Load": [ + 35 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/transcript_source_lifetime_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 71 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/viewport_body_lifetime_pilot.py", + "references": { + "clear_selection:Attribute:Load": [ + 152 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "tests/viewport_selection_diff_pilot.py", + "references": { + "_select_state:Attribute:Load": [ + 55 + ], + "_forward_event:Attribute:Load": [ + 50, + 51, + 79 + ], + "_stop_auto_scroll:Attribute:Load": [ + 53 + ], + "_update_select:Attribute:Load": [ + 74 + ], + "SelectState:Name:Load": [ + 63 + ] + } + } + ], + "limitations": "Lexical sites include distinct Input/TextArea selection owners. External subclasses/dynamic calls are unresolved; no parse omissions are treated as evidence of absence." +} diff --git a/evidence/primary-selection-gesture-20261006/before.json b/evidence/primary-selection-gesture-20261006/before.json index 6c13234d2a..cc8d7a44a8 100644 --- a/evidence/primary-selection-gesture-20261006/before.json +++ b/evidence/primary-selection-gesture-20261006/before.json @@ -76,6 +76,9 @@ "_retire_selection:FunctionDef:NoneType": [ 1547 ], + "_on_screen_suspend:FunctionDef:NoneType": [ + 1637 + ], "_start_auto_scroll:FunctionDef:NoneType": [ 1886 ], @@ -414,6 +417,27 @@ ] } }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/screens/mcp_decision.py", + "references": { + "_on_screen_suspend:FunctionDef:NoneType": [ + 66 + ], + "_on_screen_suspend:Attribute:Load": [ + 67 + ] + } + }, + { + "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", + "file": "src/toad/screens/workspace.py", + "references": { + "on_screen_suspend:FunctionDef:NoneType": [ + 119 + ] + } + }, { "checkout": "/home/ts/wt/toad-right-sidebar-latency-20261006", "file": "src/toad/widgets/conversation.py", diff --git a/evidence/primary-selection-gesture-20261006/changed-family.log b/evidence/primary-selection-gesture-20261006/changed-family.log new file mode 100644 index 0000000000..70200462d6 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/changed-family.log @@ -0,0 +1,20 @@ +..F...................... [100%] +=================================== FAILURES =================================== +___ test_real_run_async_primary_edge_scroll_stops_without_projection_revival ___ +tests/test_primary_selection_gesture.py:121: in test_real_run_async_primary_edge_scroll_stops_without_projection_revival + await asyncio.wait_for( +/usr/lib/python3.14/asyncio/tasks.py:488: in wait_for + return await fut + ^^^^^^^^^ +src/textual/app.py:2307: in run_async + await auto_pilot_task +src/textual/app.py:2279: in run_auto_pilot + await auto_pilot(pilot) +tests/test_primary_selection_gesture.py:99: in drive + assert app.screen._selecting +E AssertionError: assert False +E + where False = Screen(id='_default')._selecting +E + where Screen(id='_default') = HistoryApp(title='HistoryApp', classes={'-dark-mode'}, pseudo_classes={'nocolor', 'dark', 'focus'}).screen +=========================== short test summary info ============================ +FAILED tests/test_primary_selection_gesture.py::test_real_run_async_primary_edge_scroll_stops_without_projection_revival +1 failed, 24 passed in 26.72s diff --git a/evidence/primary-selection-gesture-20261006/run-async-final.log b/evidence/primary-selection-gesture-20261006/run-async-final.log new file mode 100644 index 0000000000..8b605c9bf2 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/run-async-final.log @@ -0,0 +1,2 @@ +. [100%] +1 passed in 1.21s diff --git a/evidence/primary-selection-gesture-20261006/suspension.log b/evidence/primary-selection-gesture-20261006/suspension.log new file mode 100644 index 0000000000..1f1f3a4083 --- /dev/null +++ b/evidence/primary-selection-gesture-20261006/suspension.log @@ -0,0 +1,2 @@ +. [100%] +1 passed in 1.19s diff --git a/src/textual/screen.py b/src/textual/screen.py index b7d704109d..d9f40bc15a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1638,6 +1638,9 @@ def _update_auto_focus(self) -> None: def _on_screen_suspend(self) -> None: """Screen has suspended.""" + # Releases now route to another Screen. Retain the copyable range, + # but retire the press and its auto-scroll through the original owner. + self._mouse_down_offset = None if self.app.SUSPENDED_SCREEN_CLASS: self.add_class(self.app.SUSPENDED_SCREEN_CLASS) self.app._set_mouse_over(None, None) diff --git a/tests/test_primary_selection_gesture.py b/tests/test_primary_selection_gesture.py new file mode 100644 index 0000000000..eb7ecbd1e8 --- /dev/null +++ b/tests/test_primary_selection_gesture.py @@ -0,0 +1,203 @@ +import asyncio + +from textual import events +from textual.app import App +from textual.containers import VerticalScroll +from textual.geometry import Offset +from textual.screen import Screen +from textual.widgets import Static + + +def pointer(app, kind, point, button): + # Real driver ingress, including its pressed-button tracking. + app._driver.process_message( + kind(None, *point, 0, 1, button, False, False, False) + ) + + +class HistoryApp(App): + CSS = "#history {width: 30; height: 6;} Static {height: 1;}" + + def __init__(self): + super().__init__() + self.selection_completions = 0 + + def compose(self): + with VerticalScroll(id="history"): + for index in range(50): + yield Static(f"original row {index:02}", id=f"row-{index}") + + def on_text_selected(self): + self.selection_completions += 1 + + +async def positioned(app, pilot): + # The mounted run_async callback precedes its first resized arrangement. + # Borrow the original publication before acquiring scroll coordinates. + await pilot.pause() + history = app.query_one("#history", VerticalScroll) + assert history.max_scroll_y > 20 + history.scroll_to(y=20, animate=False, immediate=True) + await pilot.pause() + assert history.scroll_y == 20 + first_row = app.query_one("#row-21") + first = first_row.region.offset + Offset(1, 0) + assert app.get_widget_at(*first)[0] is first_row + last = app.query_one("#row-23").region.offset + Offset(4, 0) + edge = Offset(first.x, history.content_region.bottom - 1) + assert app.screen.get_widget_and_offset_at(*edge)[0] is not None + return history, first, last, edge + + +async def test_secondary_edge_drag_does_not_acquire_selection_or_auto_scroll(): + app = HistoryApp() + async with app.run_test(size=(40, 10)) as pilot: + history, first, _, edge = await positioned(app, pilot) + original_scroll = history.scroll_y + pointer(app, events.MouseDown, first, 3) + pointer(app, events.MouseMove, edge, 3) + await pilot.pause() + assert app.screen._mouse_down_offset is None + assert app.screen._select_state is None + assert not app.screen._selecting + assert app.screen._auto_select_scroll_timer is None + pointer(app, events.MouseUp, edge, 3) + await pilot.pause() + assert history.scroll_y == original_scroll + assert app.screen.get_selected_text() is None + assert app.selection_completions == 0 + + +async def test_secondary_release_preserves_primary_range_and_completion_owner(): + app = HistoryApp() + async with app.run_test(size=(40, 10)) as pilot: + _, first, last, _ = await positioned(app, pilot) + pointer(app, events.MouseDown, first, 1) + pointer(app, events.MouseMove, last, 1) + await pilot.pause() + state = app.screen._select_state + text = app.screen.get_selected_text() + assert state is not None and text + pointer(app, events.MouseDown, first, 3) + pointer(app, events.MouseUp, first, 3) + await pilot.pause() + assert app.screen._mouse_down_offset == first + assert app.screen._select_state is state + assert app.screen._selecting + assert app.screen.get_selected_text() == text + assert app.selection_completions == 0 + pointer(app, events.MouseUp, last, 1) + await pilot.pause() + assert not app.screen._selecting + assert app.screen._mouse_down_offset is None + assert app.screen.get_selected_text() == text + assert app.selection_completions == 1 + + +async def test_real_run_async_primary_edge_scroll_stops_without_projection_revival(): + app = HistoryApp() + + async def drive(pilot): + history, first, _, edge = await positioned(app, pilot) + original_scroll = history.scroll_y + pointer(app, events.MouseDown, first, 1) + pointer(app, events.MouseMove, edge, 1) + await pilot.pause() + assert app.screen._selecting + assert app.screen._auto_select_scroll_timer is not None + # Allow the original timer to execute; no replacement scroll callback. + await pilot.pause(0.1) + assert history.scroll_y > original_scroll + pointer(app, events.MouseUp, edge, 1) + await pilot.pause() + screen = app.screen + assert screen.get_selected_text() + assert screen._select_state is not None + assert not screen._selecting + assert screen._auto_select_scroll_timer is None + stopped_scroll = history.scroll_y + screen._update_select() + screen._flush_pending_selection() + await pilot.pause(0.05) + assert not screen._selecting + assert screen._auto_select_scroll_timer is None + assert history.scroll_y == stopped_scroll + assert screen.get_selected_text() + app.exit() + + await asyncio.wait_for( + app.run_async(headless=True, size=(40, 10), auto_pilot=drive), 10 + ) + assert app._exception is None + assert app._task is None + + +async def test_screen_suspend_retires_primary_scroll_but_retains_copyable_range(): + app = HistoryApp() + async with app.run_test(size=(40, 10)) as pilot: + history, first, _, edge = await positioned(app, pilot) + pointer(app, events.MouseDown, first, 1) + pointer(app, events.MouseMove, edge, 1) + await pilot.pause() + original = app.screen + assert original._auto_select_scroll_timer is not None + await app.push_screen(Screen(Static("Original modal"))) + await pilot.pause() + assert original._mouse_down_offset is None + assert not original._selecting + assert original._auto_select_scroll_timer is None + text = original.get_selected_text() + stopped_scroll = history.scroll_y + assert text + await pilot.pause(0.05) + assert history.scroll_y == stopped_scroll + app.pop_screen() + await pilot.pause() + assert app.screen is original + assert not original._selecting + assert original.get_selected_text() == text + assert original._auto_select_scroll_timer is None + + +async def test_right_capture_and_menu_keep_completed_primary_selection(): + class MenuRow(Static): + def on_mouse_down(self, event): + if event.button == 3: + self.capture_mouse() + + def on_mouse_up(self, event): + if event.button == 3: + self.release_mouse() + self.app.secondary_release = event.widget + + def on_click(self, event): + if event.button == 3: + self.app.push_screen(Screen(Static("Original right-click menu"))) + + class MenuApp(HistoryApp): + def compose(self): + with VerticalScroll(id="history"): + for index in range(50): + yield MenuRow(f"original row {index:02}", id=f"row-{index}") + + app = MenuApp() + async with app.run_test(size=(40, 10)) as pilot: + _, first, last, _ = await positioned(app, pilot) + pointer(app, events.MouseDown, first, 1) + pointer(app, events.MouseMove, last, 1) + pointer(app, events.MouseUp, last, 1) + await pilot.pause() + original = app.screen + selected_text = original.get_selected_text() + state = original._select_state + assert selected_text and state is not None + row = app.query_one("#row-22", MenuRow) + assert await pilot.click(row, button=3) + assert app.secondary_release is row + assert app.mouse_captured is None + assert app.screen is not original + assert original._select_state is state + assert original.get_selected_text() == selected_text + assert not original._selecting + assert original._auto_select_scroll_timer is None + assert app.selection_completions == 1