gtk: wire up occlusionCallback for non-focused tabs (#12760)

As discussed in #12745, there has been an outstanding plan to make
rendering behavior for non-focused surfaces consistent across platforms.
This PR does that for Linux/GTK using the same patterns as OSX.

The change in `src/apprt/gtk/class/surface.zig` piggybacks on the
existing `glareaMap` / `glareaUnmap` callbacks (added by `e59e27f8b`) by
also calling a new `updateOcclusion(bool)` helper. If you don't like the
helper, or want the helper lifted up further and used on other paths,
let me know and I can revise.

The changes in `src/renderer/Thread.zig` bail on `renderCallback` when
not visible and then block on `drainMailbox` to complete the "catch up"
before trying to draw again.

I want to note that this is more granular than the original #1512, which
was just focused on the top level window state. I can look at that as
well if you want, but given the complexity around how
`XDG_TOPLEVEL_STATE_SUSPENDED` event is fired, I would want to make sure
we discussed things like transparency and single-instance properly first
(e.g., do we render when behind another transparent window).

## Testing

Here's a summary of what I tested:

Tested on Linux/GTK (Ubuntu 26.04, GTK 4.22.2, libadwaita 1.9.0,
Wayland), built `ReleaseFast`. The patched binary has been daily-driven
for ~24 hours as my primary terminal.

| Test | Workload | Result |
|---|---|---|
| Daily drive | byobu × multiple SSH sessions, Claude Code and Codex
producing sustained streaming output, `top` / `btop` redrawing on 1 s
intervals, frequent tab switching | No observed issues over ~24 hours of
mixed use |
| Bell on hidden tab | `sleep 5 && printf '\a'` in background tab | Bell
+ needs-attention indicator both fire; confirms IO-thread → GTK-signal
path is untouched |
| Search highlight survives hide/show | Open search w/ matches
highlighted in tab B → switch to tab A for ~10 s → switch back |
Highlights restored instantly with no stale state; confirms
deferred-replay path (`updateFrame` on `.visible → true`) works
correctly |
| Selection persistence | Select text in tab B → switch tabs → switch
back | Selection preserved exactly |
| Lifecycle (close-all) | Opened 8 surfaces, closed them one at a time,
then process exit + systemd restart | Zero `glib-CRITICAL`, zero `error
in occlusion callback ...` warnings, clean teardown per `journalctl
--user -u app-com.mitchellh.ghostty` |
| Per-thread CPU during workload | `pidstat -t -p <pid>` 30 s with 1
byobu surface focused, 1 background | Hidden surface's renderer thread
sits at 0.00 % every sample; focused surface's renderer shows ~1 % blips
on byobu status ticks |



## AI usage 

Claude Code (Opus 4.7) helped review my patch and monitor / summarize
the jorunald log and pidstat entries.
This commit is contained in:
Mitchell Hashimoto
2026-05-22 08:54:02 -07:00
committed by GitHub
2 changed files with 23 additions and 4 deletions

View File

@@ -3283,6 +3283,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(true);
self.updateOcclusion(true);
}
fn glareaUnmap(
@@ -3290,6 +3291,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(false);
self.updateOcclusion(false);
}
fn updateMapped(self: *Self, mapped: bool) void {
@@ -3298,6 +3300,13 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
}
fn updateOcclusion(self: *Self, visible: bool) void {
const surface = self.core() orelse return;
surface.occlusionCallback(visible) catch |err| {
log.warn("error in occlusion callback err={}", .{err});
};
}
fn glareaRender(
_: *gtk.GLArea,
_: *gdk.GLContext,

View File

@@ -360,10 +360,16 @@ fn drainMailbox(self: *Thread) !void {
// Visibility affects our QoS class
self.setQosClass();
// If we became visible then we immediately trigger a draw.
// We don't need to update frame data because that should
// still be happening.
if (v) self.drawFrame(false);
// If we became visible then we immediately rebuild cells
// (renderCallback skips updateFrame while invisible) and draw.
if (v) {
self.renderer.updateFrame(
self.state,
self.flags.cursor_blink_visible,
) catch |err|
log.warn("error rendering on visibility regain err={}", .{err});
self.drawFrame(false);
}
// Notify the renderer so it can update any state.
self.renderer.setVisible(v);
@@ -606,6 +612,10 @@ fn renderCallback(
return .disarm;
};
// If we're not visible there's no point spending CPU rebuilding cells —
// we'll catch up when the .visible mailbox message flips us back on.
if (!t.flags.visible) return .disarm;
// Update our frame data
t.renderer.updateFrame(
t.state,