Skip to content

Click events not generated in Ghostty terminal inside tmux (widget identity check fails) #6356

@brendandebeasi

Description

@brendandebeasi

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_click handlers
  • 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 is identity 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)

  1. 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)
  2. Forwarding to screen: self.screen._forward_event(click_event)

  3. Setting app_focus on first MouseDown

  4. 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

  1. Why might get_widget_at() return different widget instances between MouseDown and MouseUp in Ghostty+tmux specifically?

  2. Is the identity check (is) intentional, or could it be changed to compare widget IDs instead?

  3. Is there a recommended way to handle this at the application level without modifying every widget?

  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions