Skip to content

An open menu silences the tooltips behind it - #206

Merged
ghackett merged 1 commit into
mainfrom
menu-mutes-background-tooltips
Aug 8, 2026
Merged

An open menu silences the tooltips behind it#206
ghackett merged 1 commit into
mainfrom
menu-mutes-background-tooltips

Conversation

@ghackett

@ghackett ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member

A context menu grabs the pointer, but GTK's tooltip machinery doesn't follow
the grab. gtk_main_do_event hands the tooltip code the widget it picked
under the pointer, not the widget the grab routes the event to, so with a menu
open you can still sweep the pointer across the window behind it and pop
tooltips — for controls the click can no longer even reach. The bubble lands
over or behind the menu depending on how the compositor stacks the two
surfaces.

Now nothing outside an open menu answers a tooltip query. The menu's own
tooltips are untouched — those are the ones that belong on screen.

Before After
Context menu open in the sidebar with a stray Toggle sidebar tooltip floating in the header Same context menu open, no tooltip

Both shots are composited: the window render never contains a popover (its own
surface) and never contains a tooltip (GtkTooltipWindow is private), so the
menu and the bubble are rendered separately and pasted on. The bubble is drawn
by a widget with GTK's own tooltip CSS node, positioned where GTK hangs a
tooltip — a faithful reconstruction of the stray tooltip rather than a capture
of one, since a headless run has no pointer to hover with.

How it works

GTK4 dropped the gtk-enable-tooltips setting, so there's no switch to flip.
What there is: gtk_tooltip_run_requery only asks a widget for a tooltip while
its has-tooltip flag is set. Clearing that flag is the mute, restoring it
when the last menu closes is the unmute.

collins/tooltipmute.py hangs it all off emission hooks rather than a call at
each popup site, so menus we don't build ourselves — AdwTabView's tab menu,
every GtkMenuButton's, VTE's — are covered too:

  • map/unmap on any autohiding popover open and close the mute. Only a
    popover that autohides takes the grab that makes the UI behind it
    untouchable; one that doesn't leaves that UI live, tooltips included.
  • The sweep walks every toplevel, skipping the open menus, and finishes with a
    requery per window so a bubble already on screen retracts. (Usually moot —
    the button press that opens a context menu hides the tooltip itself — but a
    menu opened from the keyboard gets no such press.)
  • A query-tooltip hook, live only while something is muted, catches widgets
    built after the sweep. GTK asks a widget for its tooltip twice: once when the
    pointer settles on it, then again half a second later when it's about to show
    the bubble. By that second ask the hook has muted the widget and its
    ancestors, so the requery walks off the top of the window empty-handed.

Verification

No unit test: the module is GTK all the way down, and CI's pytest runner has no
typelibs. Checked instead with headless probes driving a real Collins instance
(staged data, isolated app id) — with a sidebar row's context menu open:

  • all 66 tooltip-bearing widgets in the window are muted, and all 66 are back
    after the menu closes;
  • a popover's own tooltip-bearing content stays armed while it's open;
  • a widget created while the menu is up is muted the first time it's asked for
    a tooltip, and re-armed on close;
  • a GtkMenuButton's popover (built by GTK, never by us) mutes the same way.

The sweep costs ~1.6ms on an 893-widget window, once per menu open.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FD7QTvXtBfAUDCkLa3eqZe

GTK's tooltip machinery doesn't follow the pointer grab a menu takes: it works
off the widget it picks under the pointer, so sweeping across the window behind
an open context menu still pops tooltips for controls the click can no longer
reach — bubbles that land over or behind the menu depending on how the
compositor stacks the two surfaces.

GTK4 has no gtk-enable-tooltips switch left to flip, but a widget is only asked
for a tooltip while its has-tooltip flag is set, so clearing that flag outside
the open menu is the mute and restoring it on close is the unmute. Two emission
hooks on map/unmap catch every popover, including the ones we don't build
ourselves (AdwTabView's tab menu, every GtkMenuButton's), and a query-tooltip
hook covers widgets that appear while a menu is already up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FD7QTvXtBfAUDCkLa3eqZe
@ghackett
ghackett force-pushed the menu-mutes-background-tooltips branch from 9e9c66b to e84868b Compare August 8, 2026 16:50
@ghackett
ghackett marked this pull request as ready for review August 8, 2026 16:52
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @ghackett's task in 2m 38s —— View job


Review

  • Gather context (PR diff, files, CLAUDE.md)
  • Review collins/tooltipmute.py
  • Review collins/app.py integration
  • Post review feedback

Solid, well-documented approach — hooking map/unmap + query-tooltip via emission hooks instead of call sites at every popup is a good call, and it correctly covers menus Collins doesn't build itself. One correctness issue I'd want addressed before merge, plus a couple of minor notes.

Cross-window muting bug

_mute() walks every toplevel window in the process, not just the one the newly-opened popover belongs to:

# collins/tooltipmute.py:67-77
toplevels = Gtk.Window.get_toplevels()
for i in range(toplevels.get_n_items()):
    window = toplevels.get_item(i)
    _mute_tree(window)
    window.trigger_tooltip_query()

Collins isn't single-window: session_window() opens additional session windows (collins/window.py:1220), and the editor and PR pane each get their own window (collins/window.py:3126, :3176). A popover's pointer grab only affects the toplevel it belongs to — a different window is still fully interactive, clicks land normally, and its tooltips are legitimate. But since _open/_muted are process-global and _mute()/_unmute() don't scope by window, opening a context menu in window A silences every tooltip in window B (and C, ...) until window A's menu closes. That's the opposite of the PR's stated goal ("nothing outside an open menu ... those are the ones that belong on screen") — window B has no open menu at all.

It also compounds on the unmute side: _on_unmap (line 56) only calls _unmute() once _open is completely empty, so if a menu is open in window A and one opens in window B, closing B's menu leaves A's window still muted correctly, but window B itself stays muted too until A's menu also closes — even though B now has nothing covering it.

Suggested fix direction: key the mute state (and the _open/_muted bookkeeping) by the popover's root window (widget.get_root()), and scope _mute_tree/the toplevel sweep to that window only, so each window's tooltip-muting tracks only the menus open within it.

Fix this →

Minor

  • _on_map (collins/tooltipmute.py:47) reruns the full sweep on every autohide popover map, including nested submenus of an already-open menu. Harmless given the ~1.6ms/893-widget cost cited in the PR description, but worth a comment if it's intentional rather than an oversight, since a deeply nested GtkPopoverMenu could re-sweep several times for one menu open.
  • _open/_muted are plain lists relying on GObject identity in/remove; fine at the sizes involved here, no change needed.

Everything else — the has-tooltip clear/restore mechanism, ancestor-walking in _on_query and _in_menu, the trigger_tooltip_query() call to retract an already-shown bubble, and the guard against double-muting via get_has_tooltip() — looks correct and is exercised by the headless probes described in the PR body.

@ghackett
ghackett merged commit 6fdb9ba into main Aug 8, 2026
2 checks passed
@ghackett
ghackett deleted the menu-mutes-background-tooltips branch August 8, 2026 17:06
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Addressed in 208 (this one had already merged).

On the cross-window finding: the premise that another window is "fully
interactive, clicks land normally" doesn't hold. gtk_grab_add registers the
grab on the window group, not the window — "interaction with other widgets in
the same application is blocked" — and gtk_main_do_event looks the current
grab up by the group of whatever window an event lands in, then propagates the
event to the grab widget rather than the picked one. Collins never creates a
GtkWindowGroup, so every window is in the default group: with a menu open in
window A, a click on a control in window B goes to the popover and dismisses
the menu. Muting that control's tooltip was correct — it can't be clicked.

What the review is right about is that the code said none of this, and took the
"every toplevel in the process" shortcut instead of naming the scope it meant.
208 keys the bookkeeping by Gtk.WindowGroup and skips toplevels in another
group, which mutes the same set today and the right set if a window is ever
given its own group — verified with a two-window probe that puts the second
window in its own group and watches its 47 tooltips survive a menu in the
first. The nested-menu re-sweep is intentional and is now commented (and a
GtkPopoverMenu's submenus don't reach that path at all — they're pages of one
popover, not popovers of their own).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant