-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
Summary
When running a Textual app inside tmux with Ghostty terminal, single clicks don't work - users have to double-click to trigger click handlers. The issue appears to be in the Click event generation logic where get_widget_at() returns different widget instances between MouseDown and MouseUp events, causing the identity check (is) to fail.
Environment
- Textual version: 7.5.0
- Python version: 3.11.13
- Terminal: Ghostty
- Running inside: tmux
- OS: macOS (Darwin 25.2.0)
Observed Behavior
- Single clicks do not trigger
on_clickhandlers - Double-clicking works
- Keyboard navigation works fine
- Mouse hover/tracking works fine
- The same app works correctly outside of tmux
- Other TUI apps work fine in the same tmux+Ghostty setup
Root Cause Analysis
I traced the issue to app.py lines 3090-3092:
if (
self.get_widget_at(event.x, event.y)[0]
is self._mouse_down_widget
):
click_event = events.Click.from_event(event)
self.screen._forward_event(click_event)The Click event is only generated when the widget at MouseUp position is the same object (is comparison) as the widget saved during MouseDown. In Ghostty+tmux, get_widget_at() appears to return different widget instances between the two calls, even when clicking on the same widget at the same position.
Debugging Evidence
I added logging and confirmed:
- MouseDown and MouseUp coordinates are identical (same x, y, screen_x, screen_y)
- Both calls to
get_widget_at()return widgets with the same ID and type - However, the
isidentity check fails
Workaround That Works
Adding an on_mouse_up handler to widgets works reliably:
def on_mouse_up(self, event: events.MouseUp) -> None:
"""Handle mouse up directly since Click events fail in Ghostty+tmux."""
option_index = event.style.meta.get("option")
if option_index is None:
scroll_y = int(self.scroll_offset.y)
option_index = event.y + scroll_y
if option_index is not None and 0 <= option_index < self.option_count:
option = self.get_option_at_index(option_index)
if option and not option.disabled:
self.highlighted = option_index
# Handle selection...Attempted Fixes (None Worked)
-
Generating Click events in App.on_event based on position matching instead of identity:
async def on_event(self, event) -> None: if isinstance(event, MouseDown): self._last_mouse_down_pos = (event.screen_x, event.screen_y) elif isinstance(event, MouseUp): if self._last_mouse_down_pos == (event.screen_x, event.screen_y): widget, region = self.get_widget_at(event.screen_x, event.screen_y) click_event = Click(widget=widget, ...) widget.post_message(click_event)
-
Forwarding to screen:
self.screen._forward_event(click_event) -
Setting app_focus on first MouseDown
-
Generating Click after super().on_event() completes
None of these approaches made single-clicks work for widgets other than the one with the on_mouse_up workaround.
Questions
-
Why might
get_widget_at()return different widget instances between MouseDown and MouseUp in Ghostty+tmux specifically? -
Is the identity check (
is) intentional, or could it be changed to compare widget IDs instead? -
Is there a recommended way to handle this at the application level without modifying every widget?
-
Are there any known issues with tmux or specific terminal emulators affecting widget lookup?
Minimal Reproduction
I haven't created a minimal reproduction yet, but can do so if helpful. The issue occurs with any clickable widget (OptionList, Button, etc.) when running in Ghostty inside tmux.