mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 23:28:41 +00:00
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
374 lines
13 KiB
Zig
374 lines
13 KiB
Zig
//! This file contains all the terminal modes that we support
|
|
//! and various support types for them: an enum of supported modes,
|
|
//! a packed struct to store mode values, a more generalized state
|
|
//! struct to store values plus handle save/restore, and much more.
|
|
//!
|
|
//! There is pretty heavy comptime usage and type generation here.
|
|
//! I don't love to have this sort of complexity but its a good way
|
|
//! to ensure all our various types and logic remain in sync.
|
|
|
|
const std = @import("std");
|
|
const testing = std.testing;
|
|
|
|
/// A struct that maintains the state of all the settable modes.
|
|
pub const ModeState = struct {
|
|
/// The values of the current modes.
|
|
values: ModePacked = .{},
|
|
|
|
/// The saved values. We only allow saving each mode once.
|
|
/// This is in line with other terminals that implement XTSAVE
|
|
/// and XTRESTORE. We can improve this in the future if it becomes
|
|
/// a real-world issue but we need to be aware of a DoS vector.
|
|
saved: ModePacked = .{},
|
|
|
|
/// The default values for the modes. This is used to reset
|
|
/// the modes to their default values during reset.
|
|
default: ModePacked = .{},
|
|
|
|
/// Reset the modes to their default values. This also clears the
|
|
/// saved state.
|
|
pub fn reset(self: *ModeState) void {
|
|
self.values = self.default;
|
|
self.saved = .{};
|
|
}
|
|
|
|
/// Set a mode to a value.
|
|
pub fn set(self: *ModeState, mode: Mode, value: bool) void {
|
|
switch (mode) {
|
|
inline else => |mode_comptime| {
|
|
const entry = comptime entryForMode(mode_comptime);
|
|
@field(self.values, entry.name) = value;
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Get the value of a mode.
|
|
pub fn get(self: *const ModeState, mode: Mode) bool {
|
|
switch (mode) {
|
|
inline else => |mode_comptime| {
|
|
const entry = comptime entryForMode(mode_comptime);
|
|
return @field(self.values, entry.name);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Save the state of the given mode. This can then be restored
|
|
/// with restore. This will only be accurate if the previous
|
|
/// mode was saved exactly once and not restored. Otherwise this
|
|
/// will just keep restoring the last stored value in memory.
|
|
pub fn save(self: *ModeState, mode: Mode) void {
|
|
switch (mode) {
|
|
inline else => |mode_comptime| {
|
|
const entry = comptime entryForMode(mode_comptime);
|
|
@field(self.saved, entry.name) = @field(self.values, entry.name);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// See save. This will return the restored value.
|
|
pub fn restore(self: *ModeState, mode: Mode) bool {
|
|
switch (mode) {
|
|
inline else => |mode_comptime| {
|
|
const entry = comptime entryForMode(mode_comptime);
|
|
@field(self.values, entry.name) = @field(self.saved, entry.name);
|
|
return @field(self.values, entry.name);
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Return a DECRPM report for the given mode tag. If the tag does
|
|
/// not correspond to a known mode, the report state is .not_recognized.
|
|
pub fn getReport(self: *const ModeState, tag: ModeTag) Report {
|
|
const mode = modeFromInt(tag.value, tag.ansi) orelse return .{
|
|
.tag = tag,
|
|
.state = .not_recognized,
|
|
};
|
|
return .{
|
|
.tag = tag,
|
|
.state = if (self.get(mode)) .set else .reset,
|
|
};
|
|
}
|
|
|
|
test {
|
|
// We have this here so that we explicitly fail when we change the
|
|
// size of modes. The size of modes is NOT particularly important,
|
|
// we just want to be mentally aware when it happens.
|
|
try std.testing.expectEqual(8, @sizeOf(ModePacked));
|
|
}
|
|
};
|
|
|
|
/// A packed struct of all the settable modes. This shouldn't
|
|
/// be used directly but rather through the ModeState struct.
|
|
pub const ModePacked = packed_struct: {
|
|
const StructField = std.builtin.Type.StructField;
|
|
var names: [entries.len][]const u8 = undefined;
|
|
var types = [_]type{bool} ** entries.len;
|
|
var attrs: [entries.len]StructField.Attributes = undefined;
|
|
|
|
for (entries, &names, &attrs) |entry, *name, *attr| {
|
|
name.* = entry.name;
|
|
attr.* = .{ .default_value_ptr = &entry.default };
|
|
}
|
|
|
|
break :packed_struct @Struct(.@"packed", null, &names, &types, &attrs);
|
|
};
|
|
|
|
/// An enum(u16) of the available modes. See entries for available values.
|
|
pub const Mode = mode_enum: {
|
|
var names: [entries.len][]const u8 = undefined;
|
|
var values: [entries.len]ModeTag.Backing = undefined;
|
|
for (entries, &names, &values) |entry, *name, *value| {
|
|
name.* = entry.name;
|
|
value.* = @bitCast(ModeTag{
|
|
.value = entry.value,
|
|
.ansi = entry.ansi,
|
|
});
|
|
}
|
|
|
|
break :mode_enum @Enum(ModeTag.Backing, .exhaustive, &names, &values);
|
|
};
|
|
|
|
/// The tag type for our enum is a u16 but we use a packed struct
|
|
/// in order to pack the ansi bit into the tag.
|
|
pub const ModeTag = packed struct(u16) {
|
|
pub const Backing = @typeInfo(@This()).@"struct".backing_integer.?;
|
|
value: u15,
|
|
ansi: bool = false,
|
|
|
|
pub fn fromMode(mode: Mode) ModeTag {
|
|
return @bitCast(@intFromEnum(mode));
|
|
}
|
|
|
|
test "order" {
|
|
const t: ModeTag = .{ .value = 1 };
|
|
const int: Backing = @bitCast(t);
|
|
try std.testing.expectEqual(@as(Backing, 1), int);
|
|
}
|
|
};
|
|
|
|
pub fn modeFromInt(v: u16, ansi: bool) ?Mode {
|
|
inline for (entries) |entry| {
|
|
if (comptime !entry.disabled) {
|
|
if (entry.value == v and entry.ansi == ansi) {
|
|
const tag: ModeTag = .{ .ansi = ansi, .value = entry.value };
|
|
const int: ModeTag.Backing = @bitCast(tag);
|
|
return @enumFromInt(int);
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// A DECRPM mode report response.
|
|
pub const Report = struct {
|
|
tag: ModeTag,
|
|
state: State,
|
|
|
|
pub const max_size = max_size: {
|
|
// Construct the largest possible report in terms of values.
|
|
const report: Report = .{
|
|
.tag = .{
|
|
.value = std.math.maxInt(u15),
|
|
.ansi = false,
|
|
},
|
|
.state = .permanently_reset,
|
|
};
|
|
|
|
var discarding: std.Io.Writer.Discarding = .init(&.{});
|
|
report.encode(&discarding.writer) catch unreachable;
|
|
break :max_size discarding.count;
|
|
};
|
|
|
|
/// The state of a mode as reported in a DECRPM response.
|
|
pub const State = enum(u8) {
|
|
not_recognized = 0,
|
|
set = 1,
|
|
reset = 2,
|
|
permanently_set = 3,
|
|
permanently_reset = 4,
|
|
};
|
|
|
|
/// Encode the DECRPM report sequence.
|
|
pub fn encode(
|
|
self: Report,
|
|
writer: *std.Io.Writer,
|
|
) std.Io.Writer.Error!void {
|
|
try writer.print("\x1B[{s}{};{}$y", .{
|
|
if (self.tag.ansi) "" else "?",
|
|
self.tag.value,
|
|
@intFromEnum(self.state),
|
|
});
|
|
}
|
|
};
|
|
|
|
fn entryForMode(comptime mode: Mode) ModeEntry {
|
|
@setEvalBranchQuota(10_000);
|
|
const name = @tagName(mode);
|
|
for (entries) |entry| {
|
|
if (std.mem.eql(u8, entry.name, name)) return entry;
|
|
}
|
|
|
|
unreachable;
|
|
}
|
|
|
|
/// A single entry of a possible mode we support. This is used to
|
|
/// dynamically define the enum and other tables.
|
|
const ModeEntry = struct {
|
|
name: [:0]const u8,
|
|
value: comptime_int,
|
|
default: bool = false,
|
|
|
|
/// True if this is an ANSI mode, false if its a DEC mode (?-prefixed).
|
|
ansi: bool = false,
|
|
|
|
/// If true, this mode is disabled and Ghostty will not allow it to be
|
|
/// set or queried. The mode enum still has it, allowing Ghostty developers
|
|
/// to develop a mode without exposing it to real users.
|
|
disabled: bool = false,
|
|
};
|
|
|
|
/// The full list of available entries. For documentation see how
|
|
/// they're used within Ghostty or google their values. It is not
|
|
/// valuable to redocument them all here.
|
|
///
|
|
/// NOTE: When adding a new mode entry, also add a corresponding
|
|
/// GHOSTTY_MODE_* macro in include/ghostty/vt/modes.h.
|
|
const entries: []const ModeEntry = &.{
|
|
// ANSI
|
|
.{ .name = "disable_keyboard", .value = 2, .ansi = true }, // KAM
|
|
.{ .name = "insert", .value = 4, .ansi = true },
|
|
.{ .name = "send_receive_mode", .value = 12, .ansi = true, .default = true }, // SRM
|
|
.{ .name = "linefeed", .value = 20, .ansi = true },
|
|
|
|
// DEC
|
|
.{ .name = "cursor_keys", .value = 1 }, // DECCKM
|
|
.{ .name = "132_column", .value = 3 },
|
|
.{ .name = "slow_scroll", .value = 4 },
|
|
.{ .name = "reverse_colors", .value = 5 },
|
|
.{ .name = "origin", .value = 6 },
|
|
.{ .name = "wraparound", .value = 7, .default = true },
|
|
.{ .name = "autorepeat", .value = 8 },
|
|
.{ .name = "mouse_event_x10", .value = 9 },
|
|
.{ .name = "cursor_blinking", .value = 12 },
|
|
.{ .name = "cursor_visible", .value = 25, .default = true },
|
|
.{ .name = "enable_mode_3", .value = 40 },
|
|
.{ .name = "reverse_wrap", .value = 45 },
|
|
.{ .name = "alt_screen_legacy", .value = 47 },
|
|
.{ .name = "keypad_keys", .value = 66 },
|
|
// DEC Backarrow Key Mode (DECBKM)
|
|
// See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170
|
|
// If `false` (the default), `backspace` emits 0x7f
|
|
// If `true`, `backspace` emits 0x08
|
|
.{ .name = "backarrow_key_mode", .value = 67 },
|
|
.{ .name = "enable_left_and_right_margin", .value = 69 },
|
|
.{ .name = "mouse_event_normal", .value = 1000 },
|
|
.{ .name = "mouse_event_button", .value = 1002 },
|
|
.{ .name = "mouse_event_any", .value = 1003 },
|
|
.{ .name = "focus_event", .value = 1004 },
|
|
.{ .name = "mouse_format_utf8", .value = 1005 },
|
|
.{ .name = "mouse_format_sgr", .value = 1006 },
|
|
.{ .name = "mouse_alternate_scroll", .value = 1007, .default = true },
|
|
.{ .name = "mouse_format_urxvt", .value = 1015 },
|
|
.{ .name = "mouse_format_sgr_pixels", .value = 1016 },
|
|
.{ .name = "ignore_keypad_with_numlock", .value = 1035, .default = true },
|
|
.{ .name = "alt_esc_prefix", .value = 1036, .default = true },
|
|
.{ .name = "alt_sends_escape", .value = 1039 },
|
|
.{ .name = "reverse_wrap_extended", .value = 1045 },
|
|
.{ .name = "alt_screen", .value = 1047 },
|
|
.{ .name = "save_cursor", .value = 1048 },
|
|
.{ .name = "alt_screen_save_cursor_clear_enter", .value = 1049 },
|
|
.{ .name = "bracketed_paste", .value = 2004 },
|
|
.{ .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 },
|
|
};
|
|
|
|
test {
|
|
_ = Mode;
|
|
_ = ModePacked;
|
|
}
|
|
|
|
test modeFromInt {
|
|
try testing.expect(modeFromInt(4, true).? == .insert);
|
|
try testing.expect(modeFromInt(9, true) == null);
|
|
try testing.expect(modeFromInt(9, false).? == .mouse_event_x10);
|
|
try testing.expect(modeFromInt(14, true) == null);
|
|
}
|
|
|
|
test ModeState {
|
|
var state: ModeState = .{};
|
|
|
|
// Normal set/get
|
|
try testing.expect(!state.get(.cursor_keys));
|
|
state.set(.cursor_keys, true);
|
|
try testing.expect(state.get(.cursor_keys));
|
|
|
|
// Save/restore
|
|
state.save(.cursor_keys);
|
|
state.set(.cursor_keys, false);
|
|
try testing.expect(!state.get(.cursor_keys));
|
|
try testing.expect(state.restore(.cursor_keys));
|
|
try testing.expect(state.get(.cursor_keys));
|
|
}
|
|
|
|
test "getReport known DEC mode" {
|
|
var state: ModeState = .{};
|
|
const report = state.getReport(.{ .value = 1 });
|
|
try testing.expectEqual(Report.State.reset, report.state);
|
|
try testing.expectEqual(false, report.tag.ansi);
|
|
try testing.expectEqual(@as(u15, 1), report.tag.value);
|
|
|
|
state.set(.cursor_keys, true);
|
|
const report2 = state.getReport(.{ .value = 1 });
|
|
try testing.expectEqual(Report.State.set, report2.state);
|
|
}
|
|
|
|
test "getReport known ANSI mode" {
|
|
var state: ModeState = .{};
|
|
state.set(.insert, true);
|
|
const report = state.getReport(.{ .value = 4, .ansi = true });
|
|
try testing.expectEqual(Report.State.set, report.state);
|
|
try testing.expectEqual(true, report.tag.ansi);
|
|
}
|
|
|
|
test "getReport unknown mode" {
|
|
const state: ModeState = .{};
|
|
const report = state.getReport(.{ .value = 9999 });
|
|
try testing.expectEqual(Report.State.not_recognized, report.state);
|
|
}
|
|
|
|
test "Report.encode DEC mode set" {
|
|
var buf: [Report.max_size]u8 = undefined;
|
|
var writer: std.Io.Writer = .fixed(&buf);
|
|
const report: Report = .{ .tag = .{ .value = 1, .ansi = false }, .state = .set };
|
|
try report.encode(&writer);
|
|
try testing.expectEqualStrings("\x1B[?1;1$y", writer.buffered());
|
|
}
|
|
|
|
test "Report.encode DEC mode reset" {
|
|
var buf: [Report.max_size]u8 = undefined;
|
|
var writer: std.Io.Writer = .fixed(&buf);
|
|
const report: Report = .{ .tag = .{ .value = 1, .ansi = false }, .state = .reset };
|
|
try report.encode(&writer);
|
|
try testing.expectEqualStrings("\x1B[?1;2$y", writer.buffered());
|
|
}
|
|
|
|
test "Report.encode ANSI mode" {
|
|
var buf: [Report.max_size]u8 = undefined;
|
|
var writer: std.Io.Writer = .fixed(&buf);
|
|
const report: Report = .{ .tag = .{ .value = 4, .ansi = true }, .state = .set };
|
|
try report.encode(&writer);
|
|
try testing.expectEqualStrings("\x1B[4;1$y", writer.buffered());
|
|
}
|
|
|
|
test "Report.encode not recognized" {
|
|
var buf: [Report.max_size]u8 = undefined;
|
|
var writer: std.Io.Writer = .fixed(&buf);
|
|
const report: Report = .{ .tag = .{ .value = 9999, .ansi = false }, .state = .not_recognized };
|
|
try report.encode(&writer);
|
|
try testing.expectEqualStrings("\x1B[?9999;0$y", writer.buffered());
|
|
}
|