diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 64f7f8499..74d6b8f0f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -150,6 +150,49 @@ pub const Colors = struct { }; }; +/// Returns the current color for an xterm OSC color target. +/// +/// Unsupported dynamic and special colors return null. The cursor color +/// follows xterm-style reporting and falls back to the foreground color when +/// no explicit cursor color is set. +pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB { + return switch (target) { + .palette => |i| self.colors.palette.current[i], + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get() orelse + self.colors.foreground.get(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => null, + }, + .special => null, + }; +} + +/// Returns the current color for a Kitty color protocol key. +/// +/// Only palette, foreground, background, and cursor colors are backed by +/// Terminal state. Unsupported keys, or supported dynamic colors without a +/// value, return null. +pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB { + return switch (key) { + .palette => |palette| self.colors.palette.current[palette], + .special => |special| switch (special) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get(), + else => null, + }, + }; +} + /// This is a set of dirty flags the renderer can use to determine /// what parts of the screen need to be redrawn. It is up to the renderer /// to clear these flags. diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 5cad1aaa8..a2a75147f 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -2179,6 +2179,46 @@ test "set write_pty callback" { try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); } +test "write_pty receives OSC color query response" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + const set_fg = "\x1B]10;rgb:01/02/03\x1B\\"; + vt_write(t, set_fg, set_fg.len); + try testing.expect(S.last_data == null); + + const query_fg = "\x1B]10;?\x1B\\"; + vt_write(t, query_fg, query_fg.len); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?); +} + test "set write_pty without callback ignores queries" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/color.zig b/src/terminal/color.zig index ac6a74570..92de35781 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -516,6 +516,24 @@ pub const RGB = packed struct(u24) { return self.r == other.r and self.g == other.g and self.b == other.b; } + pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ self.r, self.g, self.b }, + ); + } + + pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, self.r) * 257, + @as(u16, self.g) * 257, + @as(u16, self.b) * 257, + }, + ); + } + /// Calculates the contrast ratio between two colors. The contrast /// ration is a value between 1 and 21 where 1 is the lowest contrast /// and 21 is the highest contrast. @@ -913,6 +931,19 @@ test "RGB.parse" { try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor")); } +test "RGB: encode" { + const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff }; + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try rgb.encodeRgb8(&writer); + try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered()); + + writer = .fixed(&buf); + try rgb.encodeRgb16(&writer); + try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered()); +} + test "DynamicPalette: init" { const testing = std.testing; diff --git a/src/terminal/kitty/color.zig b/src/terminal/kitty/color.zig index c1072c390..3d2e950c1 100644 --- a/src/terminal/kitty/color.zig +++ b/src/terminal/kitty/color.zig @@ -51,6 +51,23 @@ pub const Kind = union(enum) { return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null }; } + /// Returns true when a terminal has built-in state for this key. + /// + /// Unsupported special colors may still be valid Kitty protocol keys, but + /// libghostty-vt cannot report them because Terminal does not store them. + pub fn hasTerminalQueryColor(self: Kind) bool { + return switch (self) { + .palette => true, + .special => |special| switch (special) { + .foreground, + .background, + .cursor, + => true, + else => false, + }, + }; + } + pub fn format( self: Kind, writer: *std.Io.Writer, @@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" { const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }}); try testing.expectEqualStrings("42", actual); } + + try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor()); + try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor()); + try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor()); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 84bf81099..26ce12672 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -405,10 +405,12 @@ pub const Parser = struct { .kitty_color_protocol => |*v| kitty_color_protocol: { v.deinit(self.alloc orelse break :kitty_color_protocol); }, + .color_operation => |*v| color_operation: { + v.requests.deinit(self.alloc orelse break :color_operation); + }, .change_window_icon, .change_window_title, .clipboard_contents, - .color_operation, .conemu_change_tab_title, .conemu_comment, .conemu_guimacro, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index a0cb88012..38d59bcd2 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -8,7 +8,9 @@ const device_status = @import("device_status.zig"); const stream = @import("stream.zig"); const Action = stream.Action; const Screen = @import("Screen.zig"); +const color = @import("color.zig"); const modes = @import("modes.zig"); +const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); const kitty_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); @@ -257,7 +259,7 @@ pub const Handler = struct { .end_hyperlink => self.terminal.screens.active.endHyperlink(), .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, - .color_operation => try self.colorOperation(value.op, &value.requests), + .color_operation => try self.colorOperation(&value.requests, value.terminator), .kitty_color_report => try self.kittyColorOperation(value), // APC @@ -598,12 +600,17 @@ pub const Handler = struct { fn colorOperation( self: *Handler, - op: osc_color.Operation, requests: *const osc_color.List, + terminator: osc.Terminator, ) !void { - _ = op; if (requests.count() == 0) return; + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + var it = requests.constIterator(0); while (it.next()) |req| { switch (req.*) { @@ -661,17 +668,67 @@ pub const Handler = struct { mask.* = .initEmpty(); }, - .query, - .reset_special, - => {}, + .query => |target| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForXterm(target) orelse continue; + try writeXtermColorReport(writer, target, c, terminator); + }, + + .reset_special => {}, } } + + if (response.written().len > 0) { + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } + } + + fn writeXtermColorReport( + writer: *std.Io.Writer, + target: osc_color.Target, + c: color.RGB, + terminator: osc.Terminator, + ) !void { + switch (target) { + .palette => |i| { + try writer.print("\x1b]4;{d};", .{i}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground, + .background, + .cursor, + => { + try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + } } fn kittyColorOperation( self: *Handler, request: kitty_color.OSC, ) !void { + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + for (request.list.items) |item| { switch (item) { .set => |v| switch (v.key) { @@ -698,9 +755,28 @@ pub const Handler = struct { else => {}, }, }, - .query => {}, + .query => |key| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForKitty(key) orelse { + if (!key.hasTerminalQueryColor()) continue; + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + continue; + }; + + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + try c.encodeRgb8(writer); + }, } } + + if (response.written().len > 0) { + try writer.writeAll(request.terminator.string()); + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } } fn apcEnd(self: *Handler) void { @@ -1021,6 +1097,8 @@ test "ignores query actions" { s.nextSlice("\x1B[c"); // Device attributes s.nextSlice("\x1B[5n"); // Device status report s.nextSlice("\x1B[6n"); // Cursor position report + s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query + s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query // Terminal should still be functional s.nextSlice("Test"); @@ -1137,6 +1215,63 @@ test "OSC 12 set and reset cursor color" { // After reset, cursor might be null (using default) } +test "OSC color query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]10;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]11;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]4;2;rgb:1212/3434/5656\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\"); + s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\"); + s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\"); + s.nextSlice("\x1b]10;?;?;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]10;rgb:0101/0202/0303\x1b\\" ++ + "\x1b]11;rgb:0404/0505/0606\x1b\\" ++ + "\x1b]12;rgb:0707/0808/0909\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]112\x1b\\"); + s.nextSlice("\x1b]12;?\x07"); + try testing.expectEqualStrings( + "\x1b]12;rgb:0101/0202/0303\x07", + S.last_response.?, + ); +} + test "kitty color protocol set palette" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1231,6 +1366,46 @@ test "kitty color protocol reset foreground" { try testing.expect(t.colors.foreground.get() == null); } +test "kitty color protocol query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]21;background=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;background=\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\"); + s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\", + S.last_response.?, + ); +} + test "palette dirty flag set on color change" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator);