diff --git a/include/ghostty.h b/include/ghostty.h index fbfe3ee2c..30c6178c0 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -797,6 +797,14 @@ typedef struct { uint8_t b; } ghostty_action_color_change_s; +// apprt.action.TabColor +typedef struct { + bool reset; + uint8_t r; + uint8_t g; + uint8_t b; +} ghostty_action_tab_color_s; + // apprt.action.ConfigChange typedef struct { ghostty_config_t config; @@ -930,6 +938,7 @@ typedef enum { GHOSTTY_ACTION_KEY_SEQUENCE, GHOSTTY_ACTION_KEY_TABLE, GHOSTTY_ACTION_COLOR_CHANGE, + GHOSTTY_ACTION_TAB_COLOR, GHOSTTY_ACTION_RELOAD_CONFIG, GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, @@ -978,6 +987,7 @@ typedef union { ghostty_action_key_sequence_s key_sequence; ghostty_action_key_table_s key_table; ghostty_action_color_change_s color_change; + ghostty_action_tab_color_s tab_color; ghostty_action_reload_config_s reload_config; ghostty_action_config_change_s config_change; ghostty_action_open_url_s open_url; diff --git a/macos/Sources/Features/Terminal/TerminalTabColor.swift b/macos/Sources/Features/Terminal/TerminalTabColor.swift index 2879822b3..fb0ea0297 100644 --- a/macos/Sources/Features/Terminal/TerminalTabColor.swift +++ b/macos/Sources/Features/Terminal/TerminalTabColor.swift @@ -38,6 +38,31 @@ enum TerminalTabColor: Int, CaseIterable, Codable { } } + /// Returns the preset color closest to the given RGB components. This is + /// used to map an arbitrary color (such as one received from the iTerm2 + /// OSC 6 tab color sequence) onto Ghostty's fixed tab color palette. + static func nearest(red: UInt8, green: UInt8, blue: UInt8) -> TerminalTabColor { + let targetR = Double(red) + let targetG = Double(green) + let targetB = Double(blue) + + var best: TerminalTabColor = .none + var bestDistance = Double.greatestFiniteMagnitude + for candidate in TerminalTabColor.allCases { + guard let nsColor = candidate.displayColor?.usingColorSpace(.sRGB) else { continue } + let dr = nsColor.redComponent * 255.0 - targetR + let dg = nsColor.greenComponent * 255.0 - targetG + let db = nsColor.blueComponent * 255.0 - targetB + let distance = dr * dr + dg * dg + db * db + if distance < bestDistance { + bestDistance = distance + best = candidate + } + } + + return best + } + var displayColor: NSColor? { switch self { case .none: diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 018122760..bc4cb5fb9 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -617,6 +617,9 @@ extension Ghostty { case GHOSTTY_ACTION_COLOR_CHANGE: colorChange(app, target: target, change: action.action.color_change) + case GHOSTTY_ACTION_TAB_COLOR: + return tabColor(app, target: target, v: action.action.tab_color) + case GHOSTTY_ACTION_RING_BELL: ringBell(app, target: target) @@ -1637,6 +1640,31 @@ extension Ghostty { } } + private static func tabColor( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_tab_color_s + ) -> Bool { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("tab color does nothing with an app target") + return false + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window as? TerminalWindow else { return false } + window.tabColor = v.reset + ? .none + : .nearest(red: v.r, green: v.g, blue: v.b) + return true + + default: + assertionFailure() + return false + } + } + private static func showChildExited( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 410f717b0..4623c34bc 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1031,6 +1031,19 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { ); }, + .tab_color_change => |change| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .tab_color, + .{ + .reset = change.reset, + .r = change.color.r, + .g = change.color.g, + .b = change.color.b, + }, + ); + }, + .set_mouse_shape => |shape| { log.debug("changing mouse shape: {}", .{shape}); _ = try self.rt_app.performAction( diff --git a/src/apprt/action.zig b/src/apprt/action.zig index f6865af83..2dd4a10b4 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -265,6 +265,10 @@ pub const Action = union(Key) { /// such as OSC 10/11. color_change: ColorChange, + /// The tab's background color was changed programmatically through + /// the iTerm2 OSC 6 sequence. + tab_color: TabColor, + /// A request to reload the configuration. The reload request can be /// from a user or for some internal reason. The reload request may /// request it is a soft reload or a full reload. See the struct for @@ -392,6 +396,7 @@ pub const Action = union(Key) { key_sequence, key_table, color_change, + tab_color, reload_config, config_change, close_window, @@ -855,6 +860,15 @@ pub const ColorKind = enum(c_int) { // } }; +pub const TabColor = extern struct { + /// When true the tab color should be reset to the default and the + /// r/g/b fields should be ignored. + reset: bool, + r: u8, + g: u8, + b: u8, +}; + pub const ReloadConfig = extern struct { /// A soft reload means that the configuration doesn't need to be /// read off disk, but libghostty needs the full config again so call diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 107510b43..13187ae9b 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -780,6 +780,7 @@ pub const Application = extern struct { .render_inspector, .renderer_health, .color_change, + .tab_color, .reset_window_size, .check_for_updates, .undo, diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fa..0cdc47864 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -75,6 +75,14 @@ pub const Message = union(enum) { /// A terminal color was changed using OSC sequences. color_change: terminal.osc.color.ColoredTarget, + /// The tab's background color was changed using the iTerm2 OSC 6 + /// sequence. When `reset` is true the color should revert to the + /// default and `color` should be ignored. + tab_color_change: struct { + reset: bool, + color: terminal.color.RGB = .{ .r = 0, .g = 0, .b = 0 }, + }, + /// Notifies the surface that a tick of the timer that is timing /// out selection scrolling has occurred. "selection scrolling" /// is when the user has clicked and dragged the mouse outside diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 36cfd7f82..7fb0bf625 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -160,10 +160,40 @@ pub const Command = union(Key) { /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, + /// OSC 6. Set or reset the tab's background color. This follows the + /// iTerm2 convention where each color channel is set with a separate + /// sequence and a wildcard channel resets the color to the default. + /// https://iterm2.com/documentation-escape-codes.html + tab_color: TabColor, + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; + /// The payload for the `tab_color` command (OSC 6). iTerm2 sends one + /// channel per sequence, so consumers are expected to accumulate the + /// channels into a full color. + pub const TabColor = union(enum) { + /// Reset the tab color to the default. + reset, + + /// Set a single channel of the tab color. + set: struct { + channel: Channel, + value: u8, + }, + + pub const Channel = enum { red, green, blue }; + + // This command is fully handled on the Zig side (it is translated + // into an apprt action) so it is never exposed across the vt C ABI. + pub const C = void; + + pub fn cval(_: TabColor) TabColor.C { + return {}; + } + }; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. @@ -193,6 +223,7 @@ pub const Command = union(Key) { "kitty_text_sizing", "kitty_clipboard_protocol", "context_signal", + "tab_color", }, ); @@ -422,6 +453,7 @@ pub const Parser = struct { .kitty_text_sizing, .kitty_clipboard_protocol, .context_signal, + .tab_color, => {}, } @@ -673,6 +705,7 @@ pub const Parser = struct { }, .@"6" => switch (c) { + ';' => self.captureTrailing(.fixed), '6' => self.state = .@"66", else => self.state = .invalid, }, @@ -801,7 +834,7 @@ pub const Parser = struct { .@"3008" => parsers.context_signal.parse(self, terminator_ch), - .@"6" => null, + .@"6" => parsers.iterm2_tab_color.parse(self, terminator_ch), .@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), diff --git a/src/terminal/osc/parsers.zig b/src/terminal/osc/parsers.zig index a56b38e88..5f33ab33c 100644 --- a/src/terminal/osc/parsers.zig +++ b/src/terminal/osc/parsers.zig @@ -7,6 +7,7 @@ pub const clipboard_operation = @import("parsers/clipboard_operation.zig"); pub const color = @import("parsers/color.zig"); pub const hyperlink = @import("parsers/hyperlink.zig"); pub const iterm2 = @import("parsers/iterm2.zig"); +pub const iterm2_tab_color = @import("parsers/iterm2_tab_color.zig"); pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); diff --git a/src/terminal/osc/parsers/iterm2_tab_color.zig b/src/terminal/osc/parsers/iterm2_tab_color.zig new file mode 100644 index 000000000..3fd72fb24 --- /dev/null +++ b/src/terminal/osc/parsers/iterm2_tab_color.zig @@ -0,0 +1,147 @@ +const std = @import("std"); + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const TabColor = Command.TabColor; + +const log = std.log.scoped(.osc_tab_color); + +/// Parse OSC 6, the iTerm2 tab color sequence. +/// +/// iTerm2 sets the tab's background color one channel at a time: +/// +/// OSC 6 ; 1 ; bg ; red ; brightness ; <0-255> ST +/// OSC 6 ; 1 ; bg ; green ; brightness ; <0-255> ST +/// OSC 6 ; 1 ; bg ; blue ; brightness ; <0-255> ST +/// +/// And resets it to the default with a wildcard channel: +/// +/// OSC 6 ; 1 ; bg ; * ; default ST +/// +/// We only support the background ("bg") target since that is what colors +/// the tab. The leading mode field ("1") and the colorspace field +/// ("brightness") are accepted but not otherwise interpreted. +/// https://iterm2.com/documentation-escape-codes.html +pub fn parse(parser: *Parser, _: ?u8) ?*Command { + const data = data: { + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; + break :data cap.trailing(); + }; + + var it = std.mem.splitScalar(u8, data, ';'); + + // Mode field. iTerm2 uses "1" here; we accept any value. + _ = it.next() orelse return invalid(parser); + + // Target. We only support the tab background color. + const target = it.next() orelse return invalid(parser); + if (!std.ascii.eqlIgnoreCase(target, "bg")) return invalid(parser); + + // Channel: red, green, blue, or "*" for reset. + const channel = it.next() orelse return invalid(parser); + + if (std.mem.eql(u8, channel, "*")) { + // Reset form. The next field must be "default". + const reset = it.next() orelse return invalid(parser); + if (!std.ascii.eqlIgnoreCase(reset, "default")) return invalid(parser); + + parser.command = .{ .tab_color = .reset }; + return &parser.command; + } + + const ch: TabColor.Channel = + if (std.ascii.eqlIgnoreCase(channel, "red")) .red else if (std.ascii.eqlIgnoreCase(channel, "green")) .green else if (std.ascii.eqlIgnoreCase(channel, "blue")) .blue else return invalid(parser); + + // Colorspace field. iTerm2 uses "brightness"; we accept any value. + _ = it.next() orelse return invalid(parser); + + // Channel value, 0-255. + const value_str = it.next() orelse return invalid(parser); + const value = std.fmt.parseInt(u8, value_str, 10) catch return invalid(parser); + + parser.command = .{ .tab_color = .{ .set = .{ .channel = ch, .value = value } } }; + return &parser.command; +} + +fn invalid(parser: *Parser) ?*Command { + parser.command = .invalid; + return null; +} + +test "OSC 6: set red channel" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg;red;brightness;128") |ch| p.next(ch); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .tab_color); + try testing.expect(cmd.tab_color == .set); + try testing.expectEqual(TabColor.Channel.red, cmd.tab_color.set.channel); + try testing.expectEqual(@as(u8, 128), cmd.tab_color.set.value); +} + +test "OSC 6: set green channel max" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg;green;brightness;255") |ch| p.next(ch); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .tab_color); + try testing.expectEqual(TabColor.Channel.green, cmd.tab_color.set.channel); + try testing.expectEqual(@as(u8, 255), cmd.tab_color.set.value); +} + +test "OSC 6: reset" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg;*;default") |ch| p.next(ch); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .tab_color); + try testing.expect(cmd.tab_color == .reset); +} + +test "OSC 6: case insensitive" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;BG;Blue;Brightness;10") |ch| p.next(ch); + const cmd = p.end(null).?.*; + try testing.expect(cmd == .tab_color); + try testing.expectEqual(TabColor.Channel.blue, cmd.tab_color.set.channel); +} + +test "OSC 6: foreground target unsupported" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;fg;red;brightness;10") |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 6: unknown channel" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg;alpha;brightness;10") |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 6: value out of range" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg;red;brightness;300") |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} + +test "OSC 6: missing fields" { + const testing = std.testing; + + var p: Parser = .init(null); + for ("6;1;bg") |ch| p.next(ch); + try testing.expect(p.end(null) == null); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 9771334f9..12c0021c2 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -125,6 +125,7 @@ pub const Action = union(Key) { kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, semantic_prompt: SemanticPrompt, + tab_color: osc.Command.TabColor, pub const Key = lib.Enum( lib.target, @@ -222,6 +223,7 @@ pub const Action = union(Key) { "kitty_color_report", "color_operation", "semantic_prompt", + "tab_color", }, ); @@ -2023,6 +2025,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.kitty_color_report, v); }, + .tab_color => |v| { + self.handler.vt(.tab_color, v); + }, + .show_desktop_notification => |v| { self.handler.vt(.show_desktop_notification, .{ .title = v.title, diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index f68f088bf..6e0f03a61 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -267,6 +267,7 @@ pub const Handler = struct { .clipboard_contents, .title_push, .title_pop, + .tab_color, => {}, } } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index fb3a6b3ff..8e10a881f 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -70,6 +70,11 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// Accumulator for the iTerm2 tab color sequence (OSC 6). iTerm2 sets + /// the tab color one channel at a time, so we accumulate the channels + /// here and send the full color to the apprt on each update. + tab_color: terminal.color.RGB = .{ .r = 0, .g = 0, .b = 0 }, + /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, @@ -321,6 +326,7 @@ pub const StreamHandler = struct { }, .kitty_color_report => try self.kittyColorReport(value), .color_operation => try self.colorOperation(value.op, &value.requests, value.terminator), + .tab_color => self.tabColor(value), .end_hyperlink => try self.endHyperlink(), .active_status_display => self.terminal.status_display = value, .decaln => try self.decaln(), @@ -1206,6 +1212,23 @@ pub const StreamHandler = struct { } } + fn tabColor(self: *StreamHandler, v: terminal.osc.Command.TabColor) void { + switch (v) { + .reset => self.surfaceMessageWriter(.{ .tab_color_change = .{ .reset = true } }), + .set => |set| { + switch (set.channel) { + .red => self.tab_color.r = set.value, + .green => self.tab_color.g = set.value, + .blue => self.tab_color.b = set.value, + } + self.surfaceMessageWriter(.{ .tab_color_change = .{ + .reset = false, + .color = self.tab_color, + } }); + }, + } + } + fn colorOperation( self: *StreamHandler, op: terminal.osc.color.Operation,