terminal: add visibility reports with GTK suspension tracking (#13494)

## Summary

Applications cannot reliably determine whether an unfocused terminal is
still
visible, so focus reports alone are insufficient for avoiding
unnecessary
rendering.

This adds terminal visibility reporting by:

- implementing private mode 2033
- supporting `CSI ? 998 n` visibility queries and `CSI ? 999 ; Ps n`
responses
- reporting effective visibility changes while mode 2033 is enabled
- preserving host-owned visibility state across terminal resets
- treating unknown visibility conservatively as potentially visible

On GTK 4.12 and newer, surface visibility now combines widget mapping
with the
toplevel `suspended` state. This allows Ghostty to recognize windows
hidden on
another workspace or otherwise known by the compositor to be
non-visible.
Older GTK versions retain the existing conservative behavior.

## Testing

Added coverage for:

- mode 2033 support and enable/disable behavior
- explicit visibility queries
- immediate reports when enabling the mode
- visible and non-visible responses
- visibility persistence across terminal resets
- suppression of visibility queries in read-only mode

## AI disclosure

Amp assisted with the implementation, tests, commit messages, and this
pull
request description. I reviewed the resulting changes and understand how
they
interact with the terminal, termio, surface, and GTK visibility paths.

Implements: #13451 
Reference: https://rockorager.dev/misc/visibility-reports/
This commit is contained in:
Mitchell Hashimoto
2026-07-28 09:13:43 -07:00
committed by GitHub
12 changed files with 222 additions and 4 deletions

View File

@@ -92,6 +92,7 @@ extern "C" {
#define GHOSTTY_MODE_SYNC_OUTPUT (ghostty_mode_new(2026, false)) /**< Synchronized output */
#define GHOSTTY_MODE_GRAPHEME_CLUSTER (ghostty_mode_new(2027, false)) /**< Grapheme cluster mode */
#define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */
#define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */
#define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */
/** @} */

View File

@@ -154,6 +154,10 @@ child_exited: bool = false,
/// to let us know.
focused: bool = true,
/// Whether this surface may be visible. Unknown visibility is considered
/// visible so reporting remains conservative.
visible: bool = true,
/// Used to determine whether to continuously scroll.
selection_scroll_active: bool = false,
@@ -3310,9 +3314,27 @@ pub fn occlusionCallback(self: *Surface, visible: bool) !void {
crash.sentry.thread_state = self.crashThreadState();
defer crash.sentry.thread_state = null;
// Avoid duplicate renderer and visibility reports.
if (self.visible == visible) return;
self.visible = visible;
// Update the terminal state for synchronous queries, then notify the IO
// thread so it can emit a mode 2033 report when enabled.
self.renderer_state.mutex.lockUncancelable(global.io());
self.io.terminal.flags.visible = visible;
const report_visibility = self.io.terminal.modes.get(.report_visibility);
self.renderer_state.mutex.unlock(global.io());
if (report_visibility) {
self.queueIo(.{ .visibility_report = .{
.visible = visible,
.force = false,
} }, .unlocked);
}
_ = self.renderer_thread.mailbox.push(global.io(), .{
.visible = visible,
}, .{ .forever = {} });
try self.queueRender();
}

View File

@@ -3324,7 +3324,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(true);
self.updateOcclusion(true);
self.updateOcclusion();
}
fn glareaUnmap(
@@ -3332,7 +3332,7 @@ pub const Surface = extern struct {
self: *Self,
) callconv(.c) void {
self.updateMapped(false);
self.updateOcclusion(false);
self.updateOcclusion();
}
fn updateMapped(self: *Self, mapped: bool) void {
@@ -3341,13 +3341,23 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.mapped.impl.param_spec);
}
fn updateOcclusion(self: *Self, visible: bool) void {
/// Update the core surface visibility based on both GTK widget and
/// toplevel state. This is public so the window can call it when its
/// suspended state changes.
pub fn updateOcclusion(self: *Self) void {
const surface = self.core() orelse return;
const visible = self.private().mapped and !self.windowSuspended();
surface.occlusionCallback(visible) catch |err| {
log.warn("error in occlusion callback err={}", .{err});
};
}
fn windowSuspended(self: *Self) bool {
const native = self.as(gtk.Widget).getNative() orelse return false;
const window = gobject.ext.cast(gtk.Window, native) orelse return false;
return window.isSuspended() != 0;
}
fn glareaRender(
_: *gtk.GLArea,
_: *gdk.GLContext,

View File

@@ -1374,11 +1374,33 @@ pub const Window = extern struct {
}
}
// Notify every displayed surface when the compositor changes the
// Wayland xdg_toplevel suspended state.
_ = gobject.Object.signals.notify.connect(
self.as(gtk.Window),
*Self,
propSuspended,
self,
.{ .detail = "suspended" },
);
// When we are realized we always setup our appearance since this
// calls some winproto functions.
self.syncAppearance();
}
fn propSuspended(
_: *gtk.Window,
_: *gobject.ParamSpec,
self: *Self,
) callconv(.c) void {
const tab = self.getSelectedTab() orelse return;
const tree = tab.getSurfaceTree() orelse return;
var it = tree.iterator();
while (it.next()) |entry| entry.view.updateOcclusion();
}
fn btnNewTab(_: *adw.SplitButton, self: *Self) callconv(.c) void {
self.performBindingAction(.new_tab);
}

View File

@@ -117,6 +117,10 @@ flags: packed struct {
/// True if the window is focused.
focused: bool = true,
/// True if the terminal view may be visible. Unknown visibility is
/// represented as visible so callers behave conservatively.
visible: bool = true,
/// True if the terminal is in a password entry mode. This is set
/// to true based on termios state. This is set
/// to true based on termios state.
@@ -4645,8 +4649,13 @@ pub fn fullReset(self: *Terminal) void {
self.screens.active.reset();
// Rest our basic state
const visible = self.flags.visible;
self.modes.reset();
self.flags = .{};
self.flags = .{
// Visibility belongs to the view rather than terminal state, so a
// terminal reset must not make a hidden view potentially visible.
.visible = visible,
};
self.tabstops.reset(TABSTOP_INTERVAL);
self.previous_char = null;
self.pwd.clearRetainingCapacity();

View File

@@ -7,6 +7,13 @@ pub const ColorScheme = lib.Enum(lib.target, &.{
"dark",
});
/// The visibility state reported in response to a CSI ? 998 n query or when
/// DEC mode 2033 is enabled.
pub const Visibility = lib.Enum(lib.target, &.{
"potentially_visible",
"not_visible",
});
/// Maximum number of bytes that `encodeColorSchemeReport` will write.
pub const max_color_scheme_report_encode_size = max: {
var result: usize = 0;
@@ -33,6 +40,21 @@ pub fn encodeColorSchemeReport(
});
}
/// Maximum number of bytes that `encodeVisibilityReport` will write.
pub const max_visibility_report_encode_size = "\x1B[?999;2n".len;
/// Encode a visibility report response for CSI ? 998 n queries and DEC mode
/// 2033 notifications.
pub fn encodeVisibilityReport(
writer: *std.Io.Writer,
visibility: Visibility,
) std.Io.Writer.Error!void {
try writer.writeAll(switch (visibility) {
.potentially_visible => "\x1B[?999;1n",
.not_visible => "\x1B[?999;2n",
});
}
/// An enum(u16) of the available device status requests.
pub const Request = dsr_enum: {
var names: [entries.len][]const u8 = undefined;
@@ -91,6 +113,7 @@ const entries: []const Entry = &.{
.{ .name = "operating_status", .value = 5 },
.{ .name = "cursor_position", .value = 6 },
.{ .name = "color_scheme", .value = 996, .question = true },
.{ .name = "visibility", .value = 998, .question = true },
};
test "encode color scheme report dark" {
@@ -108,3 +131,14 @@ test "encode color scheme report light" {
try encodeColorSchemeReport(&writer, .light);
try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered());
}
test "encode visibility report" {
var buf: [max_visibility_report_encode_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try encodeVisibilityReport(&writer, .potentially_visible);
try std.testing.expectEqualStrings("\x1B[?999;1n", writer.buffered());
writer = .fixed(&buf);
try encodeVisibilityReport(&writer, .not_visible);
try std.testing.expectEqualStrings("\x1B[?999;2n", writer.buffered());
}

View File

@@ -282,6 +282,7 @@ const entries: []const ModeEntry = &.{
.{ .name = "synchronized_output", .value = 2026 },
.{ .name = "grapheme_cluster", .value = 2027 },
.{ .name = "report_color_scheme", .value = 2031 },
.{ .name = "report_visibility", .value = 2033 },
.{ .name = "in_band_size_reports", .value = 2048 },
};

View File

@@ -507,9 +507,24 @@ pub const Handler = struct {
buf[writer.end] = 0;
self.writePty(buf[0..writer.end :0]);
},
.visibility => self.sendVisibilityReport(),
}
}
fn sendVisibilityReport(self: *Handler) void {
const write_pty = self.effects.write_pty orelse return;
var buf: [device_status.max_visibility_report_encode_size + 1]u8 = undefined;
var writer: std.Io.Writer = .fixed(buf[0..device_status.max_visibility_report_encode_size]);
device_status.encodeVisibilityReport(
&writer,
if (self.terminal.flags.visible) .potentially_visible else .not_visible,
) catch return;
buf[writer.end] = 0;
write_pty(self, buf[0..writer.end :0]);
}
fn reportEnquiry(self: *Handler) void {
const func = self.effects.enquiry orelse return;
const response = func(self);
@@ -704,6 +719,8 @@ pub const Handler = struct {
.focus_event,
=> {},
.report_visibility => if (enabled) self.sendVisibilityReport(),
.mouse_event_x10 => {
if (enabled) {
self.terminal.flags.mouse_event = .x10;
@@ -2939,6 +2956,57 @@ test "device status: color scheme without callback" {
try testing.expect(S.written == null);
}
test "visibility reports" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
const S = struct {
var written: ?[]const u8 = null;
var count: usize = 0;
fn writePty(_: *Handler, data: [:0]const u8) void {
if (written) |old| testing.allocator.free(old);
written = testing.allocator.dupe(u8, data) catch @panic("OOM");
count += 1;
}
};
S.written = null;
S.count = 0;
defer if (S.written) |old| testing.allocator.free(old);
var handler: Handler = .init(&t);
handler.effects.write_pty = &S.writePty;
var s: Stream = .initAlloc(testing.allocator, handler);
defer s.deinit();
// Mode 2033 is supported and initially disabled.
s.nextSlice("\x1B[?2033$p");
try testing.expectEqualStrings("\x1B[?2033;2$y", S.written.?);
// A one-shot query reports the current state without enabling the mode.
s.nextSlice("\x1B[?998n");
try testing.expectEqualStrings("\x1B[?999;1n", S.written.?);
try testing.expect(!t.modes.get(.report_visibility));
// Enabling always sends an immediate report, even when already enabled.
t.flags.visible = false;
s.nextSlice("\x1B[?2033h");
try testing.expectEqualStrings("\x1B[?999;2n", S.written.?);
const count = S.count;
s.nextSlice("\x1B[?2033h");
try testing.expectEqual(count + 1, S.count);
// Disabling sends no report.
s.nextSlice("\x1B[?2033l");
try testing.expectEqual(count + 1, S.count);
// A terminal reset preserves the view's externally owned visibility.
s.nextSlice("\x1Bc");
s.nextSlice("\x1B[?998n");
try testing.expectEqualStrings("\x1B[?999;2n", S.written.?);
}
test "device status: readonly ignores all" {
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
@@ -2950,6 +3018,7 @@ test "device status: readonly ignores all" {
s.nextSlice("\x1B[5n");
s.nextSlice("\x1B[6n");
s.nextSlice("\x1B[?996n");
s.nextSlice("\x1B[?998n");
// Terminal should still be functional
s.nextSlice("Test");

View File

@@ -723,6 +723,30 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi
try self.queueWrite(td, writer.buffered(), false);
}
/// Sends a visibility report to the pty. Unforced reports are only sent while
/// DEC mode 2033 is enabled.
pub fn visibilityReport(
self: *Termio,
td: *ThreadData,
visible: bool,
force: bool,
) !void {
self.renderer_state.mutex.lockUncancelable(global.io());
defer self.renderer_state.mutex.unlock(global.io());
if (!force and !self.renderer_state.terminal.modes.get(.report_visibility)) {
return;
}
var buf: [terminalpkg.device_status.max_visibility_report_encode_size]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
try terminalpkg.device_status.encodeVisibilityReport(
&writer,
if (visible) .potentially_visible else .not_visible,
);
try self.queueWrite(td, writer.buffered(), false);
}
/// ThreadData is the data created and stored in the termio thread
/// when the thread is started and destroyed when the thread is
/// stopped.

View File

@@ -313,6 +313,11 @@ fn drainMailbox(
log.debug("mailbox message={s}", .{@tagName(message)});
switch (message) {
.color_scheme_report => |v| try io.colorSchemeReport(data, v.force),
.visibility_report => |v| try io.visibilityReport(
data,
v.visible,
v.force,
),
.crash => @panic("crash request, crashing intentionally"),
.change_config => |config| {
defer config.alloc.destroy(config.ptr);

View File

@@ -22,6 +22,15 @@ pub const Message = union(enum) {
force: bool,
},
/// Request a visibility report is sent to the pty.
visibility_report: struct {
/// The visibility state to report.
visible: bool,
/// Send the report even when mode 2033 is disabled.
force: bool,
},
/// Purposely crash the renderer. This is used for testing and debugging.
/// See the "crash" binding action.
crash: void,

View File

@@ -704,6 +704,13 @@ pub const StreamHandler = struct {
.size_report = .mode_2048,
}),
.report_visibility => if (enabled) self.messageWriter(.{
.visibility_report = .{
.visible = self.terminal.flags.visible,
.force = true,
},
}),
.focus_event => if (enabled) self.messageWriter(.{
.focused = self.terminal.flags.focused,
}),
@@ -820,6 +827,11 @@ pub const StreamHandler = struct {
},
.color_scheme => self.messageWriter(.{ .color_scheme_report = .{ .force = true } }),
.visibility => self.messageWriter(.{ .visibility_report = .{
.visible = self.terminal.flags.visible,
.force = true,
} }),
}
}