gtk: fix context menu hiding quick-terminal (#12843)

Fixes #12783 where opening the context menu (with right click) inside
the quick-terminal will hide the quick-terminal if autohide is enabled.

The cause of this issue is the quick-terminal window becoming inactive
and immediately active again when you open the context-menu. When the
window becomes inactive, the autohide feature hides the quick-terminal.
The temporary focus loss in GTK is triggered by GDK focus change events,
which probably originate from the windowing backend treating the context
menu as its own window. Whereas in GTK the context menu is not a
separate window but instead part of the widget tree of the window it was
opened from, so even when the context menu has focus that window is
still the active one in GTK.

As a fix `Window.propIsActive`, which implements the autohide logic,
will now do its work from a timeout callback, since there is probably no
reliable way to distinguish a temporary focus loss from a real one from
inside GTK and I'm not sure we can make any assumptions about the timing
of things happening in the windowing backend. A 100ms delay should be
long enough for the focus state to settle while still hiding the
quick-terminal quickly.

I reproduced the bug and verified the fix on Wayland with both Hyprland
and KDE. Temporary focus loss happens on X11+KDE as well, although it
doesn't matter there because there is no quick-terminal.

### AI Disclosure

No AI was used, code and comments were written by myself.
This commit is contained in:
Jeffrey C. Ollie
2026-05-29 22:44:30 -05:00
committed by GitHub

View File

@@ -220,6 +220,9 @@ pub const Window = extern struct {
/// behaves slightly differently under certain scenarios.
quick_terminal: bool = false,
/// Timeout source to react to this window becoming (in)active.
handle_active_state_source: ?c_uint = null,
/// The window decoration override. If this is not set then we'll
/// inherit whatever the config has. This allows overriding the
/// config on a per-window basis.
@@ -855,6 +858,38 @@ pub const Window = extern struct {
}
}
/// Callback to handle this window becoming active or inactive.
/// Triggered by propIsActive with a timeout to debounce temporary
/// changes in active state.
fn handleActiveState(ud: ?*anyopaque) callconv(.c) c_int {
const self: *Self = @ptrCast(@alignCast(ud orelse return 0));
const priv = self.private();
priv.handle_active_state_source = null;
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and
self.as(gtk.Window).isActive() == 0 and
self.as(gtk.Widget).isVisible() == 1)
{
self.toggleVisibility();
}
}
}
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return 0;
self.winproto().setUrgent(false) catch |err| {
log.warn(
"winproto failed to reset urgency={}",
.{err},
);
};
return 0;
}
//---------------------------------------------------------------
// Properties
@@ -1076,27 +1111,34 @@ pub const Window = extern struct {
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
// Hide quick-terminal if set to autohide
if (self.isQuickTerminal()) {
if (self.getConfig()) |cfg| {
if (cfg.get().@"quick-terminal-autohide" and
self.as(gtk.Window).isActive() == 0 and
self.as(gtk.Widget).isVisible() == 1)
{
self.toggleVisibility();
}
}
}
const priv = self.private();
// Don't change urgency if we're not the active window.
if (self.as(gtk.Window).isActive() == 0) return;
self.winproto().setUrgent(false) catch |err| {
log.warn(
"winproto failed to reset urgency={}",
.{err},
// Use a timeout callback to wait for focus state to settle,
// because depending on the windowing backend the window might
// become inactive and immediately active again. This happens
// e.g. on Wayland when opening a context menu or a submenu
// inside a context menu.
if (priv.handle_active_state_source == null) {
priv.handle_active_state_source = glib.timeoutAddFull(
// Use priority of an idle callback instead of the higher
// default timeout priority. This allows us to use a shorter
// timeout duration.
glib.PRIORITY_DEFAULT_IDLE,
// 50ms was chosen to be conservative. From testing we know
// that, depending on the backend and system performance, a
// shorter timeout or just an idle callback can be enough for
// the focus to settle. On the other hand a delay of e.g. 10ms
// does not work reliably on some slow systems. The downside
// of a high value is that some operations in handleActiveState,
// e.g. hiding the quick-terminal, will be visibly delayed.
// However, 50ms should barely be noticeable. We can change
// this in the future if necessary.
50,
handleActiveState,
self,
null,
);
};
}
}
fn propGdkSurfaceDims(
@@ -1215,6 +1257,13 @@ pub const Window = extern struct {
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
if (priv.handle_active_state_source) |v| {
if (glib.Source.remove(v) == 0) {
log.warn("unable to remove handle active state source", .{});
}
priv.handle_active_state_source = null;
}
priv.command_palette.set(null);
if (priv.config) |v| {