Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions frontends/tuiapp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"):
Expand Down
83 changes: 83 additions & 0 deletions tests/test_tui_stale_selectable.py
Original file line number Diff line number Diff line change
@@ -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()