mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
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:
@@ -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 */
|
||||
/** @} */
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
} }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user