From 08f21e835b1eb9ecf0709cc0cf1ab5afb1b65289 Mon Sep 17 00:00:00 2001 From: Hanson Mei Date: Fri, 22 May 2026 19:28:44 +0800 Subject: [PATCH] Fix stale selectable mouse events in TUI --- frontends/tuiapp_v2.py | 30 +++++++++++ tests/test_tui_stale_selectable.py | 83 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/test_tui_stale_selectable.py diff --git a/frontends/tuiapp_v2.py b/frontends/tuiapp_v2.py index 2c7244d40..f936281ec 100644 --- a/frontends/tuiapp_v2.py +++ b/frontends/tuiapp_v2.py @@ -55,6 +55,7 @@ def _ensure_tui_deps() -> None: from textual.containers import Horizontal, Vertical, VerticalScroll from textual.message import Message from textual.screen import ModalScreen + from textual.widget import Widget from textual.widgets import OptionList, SelectionList, Static, TextArea from textual.widgets.option_list import Option from textual.widgets.selection_list import Selection @@ -1140,6 +1141,9 @@ def action_cancel(self) -> None: class SelectableStatic(Static): # Widget.get_selection returns None for non-Text/Content visuals; fall back to render_line. + def has_valid_selection_parent(self) -> bool: + return isinstance(self.parent, Widget) + def get_selection(self, selection): render = getattr(self, "_ga_render", None) if render is not None: @@ -1984,6 +1988,32 @@ def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None: except Exception: pass + def _is_stale_selectable_mouse_event(self, event: events.Event) -> bool: + if not isinstance(event, (events.MouseDown, events.MouseMove)): + return False + try: + select_widget, select_offset = self.screen.get_widget_and_offset_at( + event.x, event.y + ) + except Exception: + return False + return ( + select_offset is not None + and isinstance(select_widget, SelectableStatic) + and not select_widget.has_valid_selection_parent() + ) + + async def on_event(self, event: events.Event) -> None: + if self._is_stale_selectable_mouse_event(event): + if isinstance(event, events.MouseDown): + try: + self.screen.clear_selection() + except Exception: + pass + event.stop() + return + await super().on_event(event) + def compose(self) -> ComposeResult: yield Static("", id="topbar") with Horizontal(id="body"): diff --git a/tests/test_tui_stale_selectable.py b/tests/test_tui_stale_selectable.py new file mode 100644 index 000000000..a169b0f16 --- /dev/null +++ b/tests/test_tui_stale_selectable.py @@ -0,0 +1,83 @@ +import asyncio +import unittest + +from textual import events +from textual.widget import Widget + +from frontends.tuiapp_v2 import GenericAgentTUI, SelectableStatic + + +class FakeScreen: + def __init__(self, widget, offset): + self.widget = widget + self.offset = offset + self.cleared = False + + def get_widget_and_offset_at(self, x, y): + return self.widget, self.offset + + def clear_selection(self): + self.cleared = True + + +def make_app(screen): + app = GenericAgentTUI() + app._screen_stack.append(screen) + return app + + +def mouse_down(): + return events.MouseDown(None, 3, 4, 0, 0, 1, False, False, False, 3, 4) + + +def mouse_move(): + return events.MouseMove(None, 3, 4, 0, 0, 1, False, False, False, 3, 4) + + +def mouse_up(): + return events.MouseUp(None, 3, 4, 0, 0, 1, False, False, False, 3, 4) + + +class TuiStaleSelectableTest(unittest.TestCase): + def test_stale_selectable_mouse_down_is_stopped_and_clears_selection(self): + screen = FakeScreen(SelectableStatic("stale"), object()) + app = make_app(screen) + event = mouse_down() + + self.assertTrue(app._is_stale_selectable_mouse_event(event)) + asyncio.run(app.on_event(event)) + + self.assertTrue(event._stop_propagation) + self.assertTrue(screen.cleared) + + def test_stale_selectable_mouse_move_is_stopped_without_clearing_selection(self): + screen = FakeScreen(SelectableStatic("stale"), object()) + app = make_app(screen) + event = mouse_move() + + self.assertTrue(app._is_stale_selectable_mouse_event(event)) + asyncio.run(app.on_event(event)) + + self.assertTrue(event._stop_propagation) + self.assertFalse(screen.cleared) + + def test_valid_selectable_parent_is_not_treated_as_stale(self): + widget = SelectableStatic("mounted") + parent = Widget() + widget._parent = parent + app = make_app(FakeScreen(widget, object())) + + self.assertFalse(app._is_stale_selectable_mouse_event(mouse_down())) + + def test_missing_selection_offset_and_mouse_up_are_not_treated_as_stale(self): + widget = SelectableStatic("stale") + app = make_app(FakeScreen(widget, None)) + + self.assertFalse(app._is_stale_selectable_mouse_event(mouse_down())) + + app = make_app(FakeScreen(widget, object())) + self.assertFalse(app._is_stale_selectable_mouse_event(mouse_up())) + + +if __name__ == "__main__": + unittest.main()