diff --git a/src/config/Config.zig b/src/config/Config.zig index ff599e1ee..b5e4340dd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -305,6 +305,22 @@ keybind: Keybinds = .{}, /// The default value is "detect". @"shell-integration": ShellIntegration = .detect, +/// Sets the reporting format for OSC sequences that request color information. +/// Ghostty currently supports OSC 10 (foreground) and OSC 11 (background) queries, +/// and by default the reported values are scaled-up RGB values, where each component +/// are 16 bits. This is how most terminals report these values. However, some legacy +/// applications may require 8-bit, unscaled, components. We also support turning off +/// reporting alltogether. The components are lowercase hex values. +/// +/// Allowable values are: +/// +/// * "none" - OSC 10/11 queries receive no reply +/// * "8-bit" - Color components are return unscaled, i.e. rr/gg/bb +/// * "16-bit" - Color components are returned scaled, e.g. rrrr/gggg/bbbb +/// +/// The default value is "16-bit". +@"osc-color-report-format": OSCColorReportFormat = .@"16-bit", + /// If anything other than false, fullscreen mode on macOS will not use the /// native fullscreen, but make the window fullscreen without animations and /// using a new space. It's faster than the native fullscreen mode since it @@ -1480,3 +1496,10 @@ pub const ShellIntegration = enum { fish, zsh, }; + +/// OSC 10 and 11 default color reporting format. +pub const OSCColorReportFormat = enum { + none, + @"8-bit", + @"16-bit", +}; diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index d376d5d10..06b10e140 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -248,7 +248,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action { return [3]?Action{ // Exit depends on current state if (self.state == next_state) null else switch (self.state) { - .osc_string => if (self.osc_parser.end()) |cmd| + .osc_string => if (self.osc_parser.end(c)) |cmd| Action{ .osc_dispatch = cmd } else null, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 3de1835d9..23b032daf 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -6,6 +6,7 @@ const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); pub const apc = @import("apc.zig"); +pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); pub const kitty = @import("kitty.zig"); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index f61504daf..03d3baf99 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -91,6 +91,57 @@ pub const Command = union(enum) { mouse_shape: struct { value: []const u8, }, + + /// OSC 10 and OSC 11 default color report. + report_default_color: struct { + /// OSC 10 requests the foreground color, OSC 11 the background color. + kind: DefaultColorKind, + + /// We must reply with the same string terminator (ST) as used in the + /// request. + terminator: Terminator = .st, + }, + + pub const DefaultColorKind = enum { + foreground, + background, + + pub fn code(self: DefaultColorKind) []const u8 { + return switch (self) { + .foreground => "10", + .background => "11", + }; + } + }; +}; + +/// The terminator used to end an OSC command. For OSC commands that demand +/// a response, we try to match the terminator used in the request since that +/// is most likely to be accepted by the calling program. +pub const Terminator = enum { + /// The preferred string terminator is ESC followed by \ + st, + + /// Some applications and terminals use BELL (0x07) as the string terminator. + bel, + + /// Initialize the terminator based on the last byte seen. If the + /// last byte is a BEL then we use BEL, otherwise we just assume ST. + pub fn init(ch: ?u8) Terminator { + return switch (ch orelse return .st) { + 0x07 => .bel, + else => .st, + }; + } + + /// The terminator as a string. This is static memory so it doesn't + /// need to be freed. + pub fn string(self: Terminator) []const u8 { + return switch (self) { + .st => "\x1b\\", + .bel => "\x07", + }; + } }; pub const Parser = struct { @@ -134,6 +185,7 @@ pub const Parser = struct { // but the state space is small enough that we just build it up this way. @"0", @"1", + @"10", @"11", @"13", @"133", @@ -143,6 +195,14 @@ pub const Parser = struct { @"52", @"7", + // OSC 10 is used to query the default foreground color, and to set the default foreground color. + // Only querying is currently supported. + query_default_fg, + + // OSC 11 is used to query the default background color, and to set the default background color. + // Only querying is currently supported. + query_default_bg, + // We're in a semantic prompt OSC command but we aren't sure // what the command is yet, i.e. `133;` semantic_prompt, @@ -210,12 +270,19 @@ pub const Parser = struct { }, .@"1" => switch (c) { + '0' => self.state = .@"10", '1' => self.state = .@"11", '3' => self.state = .@"13", else => self.state = .invalid, }, + .@"10" => switch (c) { + ';' => self.state = .query_default_fg, + else => self.state = .invalid, + }, + .@"11" => switch (c) { + ';' => self.state = .query_default_bg, '2' => { self.complete = true; self.command = .{ .reset_cursor_color = {} }; @@ -303,6 +370,22 @@ pub const Parser = struct { else => self.state = .invalid, }, + .query_default_fg => switch (c) { + '?' => { + self.command = .{ .report_default_color = .{ .kind = .foreground } }; + self.complete = true; + }, + else => self.state = .invalid, + }, + + .query_default_bg => switch (c) { + '?' => { + self.command = .{ .report_default_color = .{ .kind = .background } }; + self.complete = true; + }, + else => self.state = .invalid, + }, + .semantic_prompt => switch (c) { 'A' => { self.state = .semantic_option_start; @@ -449,8 +532,10 @@ pub const Parser = struct { } /// End the sequence and return the command, if any. If the return value - /// is null, then no valid command was found. - pub fn end(self: *Parser) ?Command { + /// is null, then no valid command was found. The optional terminator_ch + /// is the final character in the OSC sequence. This is used to determine + /// the response terminator. + pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { if (!self.complete) { log.warn("invalid OSC command: {s}", .{self.buf[0..self.buf_idx]}); return null; @@ -464,6 +549,11 @@ pub const Parser = struct { else => {}, } + switch (self.command) { + .report_default_color => |*c| c.terminator = Terminator.init(terminator_ch), + else => {}, + } + return self.command; } }; @@ -476,7 +566,7 @@ test "OSC: change_window_title" { p.next(';'); p.next('a'); p.next('b'); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } @@ -489,7 +579,7 @@ test "OSC: change_window_title with 2" { p.next(';'); p.next('a'); p.next('b'); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } @@ -502,7 +592,7 @@ test "OSC: prompt_start" { const input = "133;A"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.aid == null); try testing.expect(cmd.prompt_start.redraw); @@ -516,7 +606,7 @@ test "OSC: prompt_start with single option" { const input = "133;A;aid=14"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); } @@ -529,7 +619,7 @@ test "OSC: prompt_start with redraw disabled" { const input = "133;A;redraw=0"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(!cmd.prompt_start.redraw); } @@ -542,7 +632,7 @@ test "OSC: prompt_start with redraw invalid value" { const input = "133;A;redraw=42"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.redraw); try testing.expect(cmd.prompt_start.kind == .primary); @@ -556,7 +646,7 @@ test "OSC: prompt_start with continuation" { const input = "133;A;k=c"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.kind == .continuation); } @@ -569,7 +659,7 @@ test "OSC: end_of_command no exit code" { const input = "133;D"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .end_of_command); } @@ -581,7 +671,7 @@ test "OSC: end_of_command with exit code" { const input = "133;D;25"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .end_of_command); try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); } @@ -594,7 +684,7 @@ test "OSC: prompt_end" { const input = "133;B"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .prompt_end); } @@ -606,7 +696,7 @@ test "OSC: end_of_input" { const input = "133;C"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .end_of_input); } @@ -618,7 +708,7 @@ test "OSC: reset_cursor_color" { const input = "112"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .reset_cursor_color); } @@ -630,7 +720,7 @@ test "OSC: get/set clipboard" { const input = "52;s;?"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); @@ -644,7 +734,7 @@ test "OSC: get/set clipboard (optional parameter)" { const input = "52;;?"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 'c'); try testing.expect(std.mem.eql(u8, "?", cmd.clipboard_contents.data)); @@ -658,7 +748,7 @@ test "OSC: report pwd" { const input = "7;file:///tmp/example"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .report_pwd); try testing.expect(std.mem.eql(u8, "file:///tmp/example", cmd.report_pwd.value)); } @@ -671,7 +761,7 @@ test "OSC: pointer cursor" { const input = "22;pointer"; for (input) |ch| p.next(ch); - const cmd = p.end().?; + const cmd = p.end(null).?; try testing.expect(cmd == .mouse_shape); try testing.expect(std.mem.eql(u8, "pointer", cmd.mouse_shape.value)); } @@ -684,7 +774,7 @@ test "OSC: report pwd empty" { const input = "7;"; for (input) |ch| p.next(ch); - try testing.expect(p.end() == null); + try testing.expect(p.end(null) == null); } test "OSC: longer than buffer" { @@ -695,5 +785,35 @@ test "OSC: longer than buffer" { const input = "a" ** (Parser.MAX_BUF + 2); for (input) |ch| p.next(ch); - try testing.expect(p.end() == null); + try testing.expect(p.end(null) == null); +} + +test "OSC: report default foreground color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "10;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = ESC followed by \ + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .report_default_color); + try testing.expectEqual(cmd.report_default_color.kind, .foreground); + try testing.expectEqual(cmd.report_default_color.terminator, .st); +} + +test "OSC: report default background color" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "11;?"; + for (input) |ch| p.next(ch); + + // This corresponds to ST = BEL character + const cmd = p.end('\x07').?; + try testing.expect(cmd == .report_default_color); + try testing.expectEqual(cmd.report_default_color.kind, .background); + try testing.expectEqual(cmd.report_default_color.terminator, .bel); } diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c12bc4063..824d08ffa 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -861,6 +861,12 @@ pub fn Stream(comptime Handler: type) type { } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + .report_default_color => |v| { + if (@hasDecl(T, "reportDefaultColor")) { + try self.handler.reportDefaultColor(v.kind, v.terminator); + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, + else => if (@hasDecl(T, "oscUnimplemented")) try self.handler.oscUnimplemented(cmd) else diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 36deb1235..06911050a 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -68,6 +68,15 @@ grid_size: renderer.GridSize, default_cursor_style: terminal.Cursor.Style, default_cursor_blink: bool, +/// Default foreground color for OSC 10 reporting. +default_foreground_color: terminal.color.RGB, + +/// Default background color for OSC 11 reporting. +default_background_color: terminal.color.RGB, + +/// The OSC 10/11 reply style. +osc_color_report_format: configpkg.Config.OSCColorReportFormat, + /// The data associated with the currently running thread. data: ?*EventData, @@ -79,6 +88,9 @@ pub const DerivedConfig = struct { image_storage_limit: usize, cursor_style: terminal.Cursor.Style, cursor_blink: bool, + foreground: configpkg.Config.Color, + background: configpkg.Config.Color, + osc_color_report_format: configpkg.Config.OSCColorReportFormat, pub fn init( alloc_gpa: Allocator, @@ -91,6 +103,9 @@ pub const DerivedConfig = struct { .image_storage_limit = config.@"image-storage-limit", .cursor_style = config.@"cursor-style", .cursor_blink = config.@"cursor-style-blink", + .foreground = config.foreground, + .background = config.background, + .osc_color_report_format = config.@"osc-color-report-format", }; } @@ -140,6 +155,9 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec { .grid_size = opts.grid_size, .default_cursor_style = opts.config.cursor_style, .default_cursor_blink = opts.config.cursor_blink, + .default_foreground_color = config.foreground.toTerminalRGB(), + .default_background_color = config.background.toTerminalRGB(), + .osc_color_report_format = config.osc_color_report_format, .data = null, }; } @@ -204,6 +222,9 @@ pub fn threadEnter(self: *Exec, thread: *termio.Thread) !ThreadData { .grid_size = &self.grid_size, .default_cursor_style = self.default_cursor_style, .default_cursor_blink = self.default_cursor_blink, + .default_foreground_color = self.default_foreground_color, + .default_background_color = self.default_background_color, + .osc_color_report_format = self.osc_color_report_format, }, }, }; @@ -1141,6 +1162,9 @@ const StreamHandler = struct { default_cursor: bool = true, default_cursor_style: terminal.Cursor.Style, default_cursor_blink: bool, + default_foreground_color: terminal.color.RGB, + default_background_color: terminal.color.RGB, + osc_color_report_format: configpkg.Config.OSCColorReportFormat, pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); @@ -1779,4 +1803,50 @@ const StreamHandler = struct { self.ev.seen_title = false; } } + + /// Implements OSC 10 and OSC 11, which reports default foreground and + /// background color respectively. + pub fn reportDefaultColor( + self: *StreamHandler, + kind: terminal.osc.Command.DefaultColorKind, + terminator: terminal.osc.Terminator, + ) !void { + if (self.osc_color_report_format == .none) return; + + const color = switch (kind) { + .foreground => self.default_foreground_color, + .background => self.default_background_color, + }; + + var msg: termio.Message = .{ .write_small = .{} }; + const resp = switch (self.osc_color_report_format) { + .@"16-bit" => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>4}/{x:0>4}/{x:0>4}{s}", + .{ + kind.code(), + @as(u16, color.r) * 257, + @as(u16, color.g) * 257, + @as(u16, color.b) * 257, + terminator.string(), + }, + ), + + .@"8-bit" => try std.fmt.bufPrint( + &msg.write_small.data, + "\x1B]{s};rgb:{x:0>2}/{x:0>2}/{x:0>2}{s}", + .{ + kind.code(), + @as(u16, color.r), + @as(u16, color.g), + @as(u16, color.b), + terminator.string(), + }, + ), + + .none => unreachable, // early return above + }; + msg.write_small.len = @intCast(resp.len); + self.messageWriter(msg); + } };