feat(osc): add iTerm2 OSC 6 tab color support
Parse the iTerm2 OSC 6 tab color sequence and wire it to the existing
macOS tab color. Each color channel is set with its own sequence
(iTerm2 sets red/green/blue independently) and a wildcard channel
resets the color to the default:
OSC 6 ; 1 ; bg ; red ; brightness ; <0-255> ST
OSC 6 ; 1 ; bg ; * ; default ST
The parser emits a new `tab_color` command; the stream handler
accumulates the channels into a full RGB value and forwards it to the
apprt via a new `tab_color` action ({ reset, r, g, b }). On macOS the
RGB is mapped to the nearest entry in the existing tab color palette.
GTK leaves the action unimplemented for now.
Requested in discussion #12778 and issue #2509.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pull/12856/head
parent
cb36966a75
commit
14ed299ce6
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -780,6 +780,7 @@ pub const Application = extern struct {
|
|||
.render_inspector,
|
||||
.renderer_health,
|
||||
.color_change,
|
||||
.tab_color,
|
||||
.reset_window_size,
|
||||
.check_for_updates,
|
||||
.undo,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ pub const Handler = struct {
|
|||
.clipboard_contents,
|
||||
.title_push,
|
||||
.title_pop,
|
||||
.tab_color,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue