diff --git a/include/ghostty/vt/modes.h b/include/ghostty/vt/modes.h index 8e1fd9117..fbef4e52b 100644 --- a/include/ghostty/vt/modes.h +++ b/include/ghostty/vt/modes.h @@ -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 */ /** @} */ diff --git a/src/Surface.zig b/src/Surface.zig index 32e91bfa7..f494f6c82 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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(); } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index cfaf86cea..d47fd474f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -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(); diff --git a/src/terminal/device_status.zig b/src/terminal/device_status.zig index f61ee4a39..9d158f398 100644 --- a/src/terminal/device_status.zig +++ b/src/terminal/device_status.zig @@ -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()); +} diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index da80e4d6b..7403f3ffa 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -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 }, }; diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 89f48b417..7586711a4 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -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"); diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index 883d8d5de..e3b564bc9 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -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. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 4f5982208..088096780 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -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); diff --git a/src/termio/message.zig b/src/termio/message.zig index 4ee7f245e..5830a1db5 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -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, diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index b9b18f63a..ddc5eb82b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -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, + } }), } }