mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 04:39:01 +00:00
terminal: add visibility reports
Applications cannot infer whether an unfocused terminal remains visible, so focus reports are insufficient for avoiding expensive rendering while a view is hidden. Implement private mode 2033 and the visibility query/report sequences. Track conservative per-surface visibility, report every effective change while enabled, and always answer explicit queries and mode enables. Keep view visibility across terminal resets because it is owned by the host, not terminal state. Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019fa965-aa5f-7099-85b4-a9679d2c8bd3
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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