Merge remote-tracking branch 'upstream/main' into jacob/uucode

This commit is contained in:
Jacob Sandlund
2025-09-17 02:34:06 -04:00
41 changed files with 1781 additions and 1329 deletions

View File

@@ -66,6 +66,12 @@ font_grid_key: font.SharedGridSet.Key,
font_size: font.face.DesiredSize,
font_metrics: font.Metrics,
/// This keeps track of if the font size was ever modified. If it wasn't,
/// then config reloading will change the font. If it was manually adjusted,
/// we don't change it on config reload since we assume the user wants
/// a specific size.
font_size_adjusted: bool,
/// The renderer for this surface.
renderer: Renderer,
@@ -514,6 +520,7 @@ pub fn init(
.rt_surface = rt_surface,
.font_grid_key = font_grid_key,
.font_size = font_size,
.font_size_adjusted = false,
.font_metrics = font_grid.metrics,
.renderer = renderer_impl,
.renderer_thread = render_thread,
@@ -863,18 +870,24 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
}, .unlocked);
},
.color_change => |change| {
.color_change => |change| color_change: {
// Notify our apprt, but don't send a mode 2031 DSR report
// because VT sequences were used to change the color.
_ = try self.rt_app.performAction(
.{ .surface = self },
.color_change,
.{
.kind = switch (change.kind) {
.background => .background,
.foreground => .foreground,
.cursor => .cursor,
.kind = switch (change.target) {
.palette => |v| @enumFromInt(v),
.dynamic => |dyn| switch (dyn) {
.foreground => .foreground,
.background => .background,
.cursor => .cursor,
// Unsupported dynamic color change notification type
else => break :color_change,
},
// Special colors aren't supported for change notification
.special => break :color_change,
},
.r = change.color.r,
.g = change.color.g,
@@ -1440,7 +1453,21 @@ pub fn updateConfig(
// but this is easier and pretty rare so it's not a performance concern.
//
// (Calling setFontSize builds and sends a new font grid to the renderer.)
try self.setFontSize(self.font_size);
try self.setFontSize(font_size: {
// If we have manually adjusted the font size, keep it that way.
if (self.font_size_adjusted) {
log.info("font size manually adjusted, preserving previous size on config reload", .{});
break :font_size self.font_size;
}
// If we haven't, then we update to the configured font size.
// This allows config changes to update the font size. We used to
// never do this but it was a common source of confusion and people
// assumed that Ghostty was broken! This logic makes more sense.
var size = self.font_size;
size.points = std.math.clamp(config.@"font-size", 1.0, 255.0);
break :font_size size;
});
// We need to store our configs in a heap-allocated pointer so that
// our messages aren't huge.
@@ -3985,7 +4012,7 @@ pub fn cursorPosCallback(
// Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active.
if (pos.x >= 1 and pos.y >= 1 and self.selection_scroll_active) {
if (pos.y >= 1 and self.selection_scroll_active) {
self.io.queueMessage(
.{ .selection_scroll = false },
.locked,
@@ -4631,10 +4658,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
log.debug("increase font size={}", .{clamped_delta});
var size = self.font_size;
// Max point size is somewhat arbitrary.
var size = self.font_size;
size.points = @min(size.points + clamped_delta, 255);
try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
},
.decrease_font_size => |delta| {
@@ -4646,6 +4676,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = @max(1, size.points - clamped_delta);
try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
},
.reset_font_size => {
@@ -4654,6 +4687,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = self.config.original_font_size;
try self.setFontSize(size);
// Reset font size also resets the manual adjustment state
self.font_size_adjusted = false;
},
.set_font_size => |points| {
@@ -4662,6 +4698,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
var size = self.font_size;
size.points = std.math.clamp(points, 1.0, 255.0);
try self.setFontSize(size);
// Mark that we manually adjusted the font size
self.font_size_adjusted = true;
},
.prompt_surface_title => return try self.rt_app.performAction(

View File

@@ -78,10 +78,7 @@ pub const Message = union(enum) {
password_input: bool,
/// A terminal color was changed using OSC sequences.
color_change: struct {
kind: terminal.osc.Command.ColorOperation.Kind,
color: terminal.color.RGB,
},
color_change: terminal.osc.color.ColoredTarget,
/// Notifies the surface that a tick of the timer that is timing
/// out selection scrolling has occurred. "selection scrolling"

View File

@@ -20,7 +20,7 @@ const GitVersion = @import("GitVersion.zig");
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release.
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 1, .patch = 4 };
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,

View File

@@ -120,7 +120,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyResources {
// Themes
if (b.lazyDependency("iterm2_themes", .{})) |upstream| {
const install_step = b.addInstallDirectory(.{
.source_dir = upstream.path("ghostty"),
.source_dir = upstream.path(""),
.install_dir = .{ .custom = "share" },
.install_subdir = b.pathJoin(&.{ "ghostty", "themes" }),
.exclude_extensions = &.{".md"},

View File

@@ -127,9 +127,6 @@ pub const compatibility = std.StaticStringMap(
/// this within config files if you want to clear previously set values in
/// configuration files or on the CLI if you want to clear values set on the
/// CLI.
///
/// Changing this configuration at runtime will only affect new terminals, i.e.
/// new windows, tabs, etc.
@"font-family": RepeatableString = .{},
@"font-family-bold": RepeatableString = .{},
@"font-family-italic": RepeatableString = .{},
@@ -214,11 +211,12 @@ pub const compatibility = std.StaticStringMap(
///
/// For example, 13.5pt @ 2px/pt = 27px
///
/// Changing this configuration at runtime will only affect new terminals,
/// i.e. new windows, tabs, etc. Note that you may still not see the change
/// depending on your `window-inherit-font-size` setting. If that setting is
/// true, only the first window will be affected by this change since all
/// subsequent windows will inherit the font size of the previous window.
/// Changing this configuration at runtime will only affect existing
/// terminals that have NOT manually adjusted their font size in some way
/// (e.g. increased or decreased the font size). Terminals that have manually
/// adjusted their font size will retain their manually adjusted size.
/// Otherwise, the font size of existing terminals will be updated on
/// reload.
///
/// On Linux with GTK, font size is scaled according to both display-wide and
/// text-specific scaling factors, which are often managed by your desktop
@@ -515,7 +513,7 @@ pub const compatibility = std.StaticStringMap(
///
/// To specify a different theme for light and dark mode, use the following
/// syntax: `light:theme-name,dark:theme-name`. For example:
/// `light:rose-pine-dawn,dark:rose-pine`. Whitespace around all values are
/// `light:Rose Pine Dawn,dark:Rose Pine`. Whitespace around all values are
/// trimmed and order of light and dark does not matter. Both light and dark
/// must be specified in this form. In this form, the theme used will be
/// based on the current desktop environment theme.
@@ -2146,6 +2144,8 @@ keybind: Keybinds = .{},
/// from the first by a comma (`,`). Percentage and pixel sizes can be mixed
/// together: for instance, a size of `50%,500px` for a top-positioned quick
/// terminal would be half a screen tall, and 500 pixels wide.
///
/// Available since: 1.2.0
@"quick-terminal-size": QuickTerminalSize = .{},
/// The layer of the quick terminal window. The higher the layer,
@@ -5564,6 +5564,17 @@ pub const Keybinds = struct {
);
{
try self.set.put(
alloc,
.{ .key = .{ .physical = .copy } },
.{ .copy_to_clipboard = {} },
);
try self.set.put(
alloc,
.{ .key = .{ .physical = .paste } },
.{ .paste_from_clipboard = {} },
);
// On non-MacOS desktop envs (Windows, KDE, Gnome, Xfce), ctrl+insert is an
// alt keybinding for Copy and shift+ins is an alt keybinding for Paste
//

View File

@@ -38,9 +38,11 @@ pub const Shaper = switch (options.backend) {
/// for a shaping call. Note all terminal cells may be present; only
/// cells that have a glyph that needs to be rendered.
pub const Cell = struct {
/// The column that this cell occupies. Since a set of shaper cells is
/// always on the same line, only the X is stored. It is expected the
/// caller has access to the original screen cell.
/// The X position of this shaper cell relative to the offset of the
/// run. Because runs are always within a single row, it is expected
/// that the caller can reconstruct the full position of the cell by
/// using the known Y position of the cell and adding the X position
/// to the run offset.
x: u16,
/// An additional offset to apply to the rendering.

View File

@@ -17,9 +17,15 @@ pub const TextRun = struct {
/// lower the chance of hash collisions if they become a problem. If
/// there are hash collisions, it would result in rendering issues but
/// the core data would be correct.
///
/// The hash is position-independent within the row by using relative
/// cluster positions. This allows identical runs in different positions
/// to share the same cache entry, improving cache efficiency.
hash: u64,
/// The offset in the row where this run started
/// The offset in the row where this run started. This is added to the
/// X position of the final shaped cells to get the absolute position
/// in the row where they belong.
offset: u16,
/// The total number of cells produced by this run.
@@ -77,7 +83,11 @@ pub const RunIterator = struct {
// Go through cell by cell and accumulate while we build our run.
var j: usize = self.i;
while (j < max) : (j += 1) {
const cluster = j;
// Use relative cluster positions (offset from run start) to make
// the shaping cache position-independent. This ensures that runs
// with identical content but different starting positions in the
// row produce the same hash, enabling cache reuse.
const cluster = j - self.i;
const cell = &cells[j];
// If we have a selection and we're at a boundary point, then

View File

@@ -64,11 +64,35 @@ pub const Parser = struct {
const flags, const start_idx = try parseFlags(raw_input);
const input = raw_input[start_idx..];
// Find the last = which splits are mapping into the trigger
// and action, respectively.
// We use the last = because the keybind itself could contain
// raw equal signs (for the = codepoint)
const eql_idx = std.mem.lastIndexOf(u8, input, "=") orelse return Error.InvalidFormat;
// Find the equal sign. This is more complicated than it seems on
// the surface because we need to ignore equal signs that are
// part of the trigger.
const eql_idx: usize = eql: {
// TODO: We should change this parser into a real state machine
// based parser that parses the trigger fully, then yields the
// action after. The loop below is a total mess.
var offset: usize = 0;
while (std.mem.indexOfScalar(
u8,
input[offset..],
'=',
)) |offset_idx| {
// Find: '=+ctrl' or '==action'
const idx = offset + offset_idx;
if (idx < input.len - 1 and
(input[idx + 1] == '+' or
input[idx + 1] == '='))
{
offset += offset_idx + 1;
continue;
}
// Looks like the real equal sign.
break :eql idx;
}
return Error.InvalidFormat;
};
// Sequence iterator goes up to the equal, action is after. We can
// parse the action now.
@@ -698,7 +722,7 @@ pub const Action = union(enum) {
/// All actions are only undoable/redoable for a limited time.
/// For example, restoring a closed split can only be done for
/// some number of seconds since the split was closed. The exact
/// amount is configured with `TODO`.
/// amount is configured with the `undo-timeout` configuration settings.
///
/// The undo/redo actions being limited ensures that there is
/// bounded memory usage over time, closed surfaces don't continue running
@@ -2301,6 +2325,39 @@ test "parse: equals sign" {
try testing.expectError(Error.InvalidFormat, parseSingle("=ignore"));
}
test "parse: text action equals sign" {
const testing = std.testing;
{
const binding = try parseSingle("==text:=");
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
try testing.expectEqualStrings("=", binding.action.text);
}
{
const binding = try parseSingle("==text:=hello");
try testing.expectEqual(Trigger{ .key = .{ .unicode = '=' } }, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
{
const binding = try parseSingle("ctrl+==text:=hello");
try testing.expectEqual(Trigger{
.key = .{ .unicode = '=' },
.mods = .{ .ctrl = true },
}, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
{
const binding = try parseSingle("=+ctrl=text:=hello");
try testing.expectEqual(Trigger{
.key = .{ .unicode = '=' },
.mods = .{ .ctrl = true },
}, binding.trigger);
try testing.expectEqualStrings("=hello", binding.action.text);
}
}
// For Ghostty 1.2+ we changed our key names to match the W3C and removed
// `physical:`. This tests the backwards compatibility with the old format.
// Note that our backwards compatibility isn't 100% perfect since triggers

View File

@@ -47,6 +47,7 @@ pub const locales = [_][:0]const u8{
"es_AR.UTF-8",
"pt_BR.UTF-8",
"ca_ES.UTF-8",
"it_IT.UTF-8",
"bg_BG.UTF-8",
"ga_IE.UTF-8",
"hu_HU.UTF-8",

View File

@@ -2528,9 +2528,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
break :cache cells;
};
const cells = shaper_cells.?;
// Advance our index until we reach or pass
// our current x position in the shaper cells.
while (shaper_cells.?[shaper_cells_i].x < x) {
while (run.offset + cells[shaper_cells_i].x < x) {
shaper_cells_i += 1;
}
}
@@ -2769,13 +2771,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// If we encounter a shaper cell to the left of the current
// cell then we have some problems. This logic relies on x
// position monotonically increasing.
assert(cells[shaper_cells_i].x >= x);
assert(run.offset + cells[shaper_cells_i].x >= x);
// NOTE: An assumption is made here that a single cell will never
// be present in more than one shaper run. If that assumption is
// violated, this logic breaks.
while (shaper_cells_i < cells.len and cells[shaper_cells_i].x == x) : ({
while (shaper_cells_i < cells.len and run.offset + cells[shaper_cells_i].x == x) : ({
shaper_cells_i += 1;
}) {
self.addGlyph(

View File

@@ -103,7 +103,7 @@ fi
# SSH Integration
if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then
ssh() {
function ssh() {
builtin local ssh_term ssh_opts
ssh_term="xterm-256color"
ssh_opts=()

View File

@@ -915,21 +915,53 @@ test "osc: 112 incomplete sequence" {
const cmd = a[0].?.osc_dispatch;
try testing.expect(cmd == .color_operation);
try testing.expectEqual(cmd.color_operation.terminator, .bel);
try testing.expect(cmd.color_operation.source == .reset_cursor);
try testing.expect(cmd.color_operation.operations.count() == 1);
var it = cmd.color_operation.operations.constIterator(0);
try testing.expect(cmd.color_operation.op == .osc_112);
try testing.expect(cmd.color_operation.requests.count() == 1);
var it = cmd.color_operation.requests.constIterator(0);
{
const op = it.next().?;
try testing.expect(op.* == .reset);
try testing.expectEqual(
osc.Command.ColorOperation.Kind.cursor,
op.reset,
osc.color.Request{ .reset = .{ .dynamic = .cursor } },
op.*,
);
}
try std.testing.expect(it.next() == null);
}
}
test "osc: 104 empty" {
var p: Parser = init();
defer p.deinit();
p.osc_parser.alloc = std.testing.allocator;
_ = p.next(0x1B);
_ = p.next(']');
_ = p.next('1');
_ = p.next('0');
_ = p.next('4');
{
const a = p.next(0x07);
try testing.expect(p.state == .ground);
try testing.expect(a[0].? == .osc_dispatch);
try testing.expect(a[1] == null);
try testing.expect(a[2] == null);
const cmd = a[0].?.osc_dispatch;
try testing.expect(cmd == .color_operation);
try testing.expectEqual(cmd.color_operation.terminator, .bel);
try testing.expect(cmd.color_operation.op == .osc_104);
try testing.expect(cmd.color_operation.requests.count() == 1);
var it = cmd.color_operation.requests.constIterator(0);
{
const op = it.next().?;
try testing.expect(op.* == .reset_palette);
}
try std.testing.expect(it.next() == null);
}
}
test "csi: too many params" {
var p = init();
_ = p.next(0x1B);

View File

@@ -94,6 +94,85 @@ pub const Name = enum(u8) {
}
};
/// The "special colors" as denoted by xterm. These can be set via
/// OSC 5 or via OSC 4 by adding the palette length to it.
///
/// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
pub const Special = enum(u3) {
bold = 0,
underline = 1,
blink = 2,
reverse = 3,
italic = 4,
pub fn osc4(self: Special) u16 {
// "The special colors can also be set by adding the maximum
// number of colors (e.g., 88 or 256) to these codes in an
// OSC 4 control" - xterm ctlseqs
const max = @typeInfo(Palette).array.len;
return @as(u16, @intCast(@intFromEnum(self))) + max;
}
test "osc4" {
const testing = std.testing;
try testing.expectEqual(256, Special.bold.osc4());
try testing.expectEqual(257, Special.underline.osc4());
try testing.expectEqual(258, Special.blink.osc4());
try testing.expectEqual(259, Special.reverse.osc4());
try testing.expectEqual(260, Special.italic.osc4());
}
};
test Special {
_ = Special;
}
/// The "dynamic colors" as denoted by xterm. These can be set via
/// OSC 10 through 19.
pub const Dynamic = enum(u5) {
foreground = 10,
background = 11,
cursor = 12,
pointer_foreground = 13,
pointer_background = 14,
tektronix_foreground = 15,
tektronix_background = 16,
highlight_background = 17,
tektronix_cursor = 18,
highlight_foreground = 19,
/// The next dynamic color sequentially. This is required because
/// specifying colors sequentially without their index will automatically
/// use the next dynamic color.
///
/// "Each successive parameter changes the next color in the list. The
/// value of Ps tells the starting point in the list."
pub fn next(self: Dynamic) ?Dynamic {
return std.meta.intToEnum(
Dynamic,
@intFromEnum(self) + 1,
) catch null;
}
test "next" {
const testing = std.testing;
try testing.expectEqual(.background, Dynamic.foreground.next());
try testing.expectEqual(.cursor, Dynamic.background.next());
try testing.expectEqual(.pointer_foreground, Dynamic.cursor.next());
try testing.expectEqual(.pointer_background, Dynamic.pointer_foreground.next());
try testing.expectEqual(.tektronix_foreground, Dynamic.pointer_background.next());
try testing.expectEqual(.tektronix_background, Dynamic.tektronix_foreground.next());
try testing.expectEqual(.highlight_background, Dynamic.tektronix_background.next());
try testing.expectEqual(.tektronix_cursor, Dynamic.highlight_background.next());
try testing.expectEqual(.highlight_foreground, Dynamic.tektronix_cursor.next());
try testing.expectEqual(null, Dynamic.highlight_foreground.next());
}
};
test Dynamic {
_ = Dynamic;
}
/// RGB
pub const RGB = packed struct(u24) {
r: u8 = 0,

File diff suppressed because it is too large Load Diff

705
src/terminal/osc/color.zig Normal file
View File

@@ -0,0 +1,705 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const DynamicColor = @import("../color.zig").Dynamic;
const SpecialColor = @import("../color.zig").Special;
const RGB = @import("../color.zig").RGB;
pub const ParseError = Allocator.Error || error{
MissingOperation,
};
/// The possible operations we support for colors.
pub const Operation = enum {
osc_4,
osc_5,
osc_10,
osc_11,
osc_12,
osc_13,
osc_14,
osc_15,
osc_16,
osc_17,
osc_18,
osc_19,
osc_104,
osc_105,
osc_110,
osc_111,
osc_112,
osc_113,
osc_114,
osc_115,
osc_116,
osc_117,
osc_118,
osc_119,
};
/// Parse any color operation string. This should NOT include the operation
/// itself, but only the body of the operation. e.g. for "4;a;b;c" the body
/// should be "a;b;c" and the operation should be set accordingly.
///
/// Color parsing is fairly complicated so we pull this out to a specialized
/// function rather than go through our OSC parsing state machine. This is
/// much slower and requires more memory (since we need to buffer the full
/// request) but grants us an easier to understand and testable implementation.
///
/// If color changing ends up being a bottleneck we can optimize this later.
pub fn parse(
alloc: Allocator,
op: Operation,
buf: []const u8,
) ParseError!List {
var it = std.mem.tokenizeScalar(u8, buf, ';');
return switch (op) {
.osc_4 => try parseGetSetAnsiColor(alloc, .osc_4, &it),
.osc_5 => try parseGetSetAnsiColor(alloc, .osc_5, &it),
.osc_104 => try parseResetAnsiColor(alloc, .osc_104, &it),
.osc_105 => try parseResetAnsiColor(alloc, .osc_105, &it),
.osc_10 => try parseGetSetDynamicColor(alloc, .foreground, &it),
.osc_11 => try parseGetSetDynamicColor(alloc, .background, &it),
.osc_12 => try parseGetSetDynamicColor(alloc, .cursor, &it),
.osc_13 => try parseGetSetDynamicColor(alloc, .pointer_foreground, &it),
.osc_14 => try parseGetSetDynamicColor(alloc, .pointer_background, &it),
.osc_15 => try parseGetSetDynamicColor(alloc, .tektronix_foreground, &it),
.osc_16 => try parseGetSetDynamicColor(alloc, .tektronix_background, &it),
.osc_17 => try parseGetSetDynamicColor(alloc, .highlight_background, &it),
.osc_18 => try parseGetSetDynamicColor(alloc, .tektronix_cursor, &it),
.osc_19 => try parseGetSetDynamicColor(alloc, .highlight_foreground, &it),
.osc_110 => try parseResetDynamicColor(alloc, .foreground, &it),
.osc_111 => try parseResetDynamicColor(alloc, .background, &it),
.osc_112 => try parseResetDynamicColor(alloc, .cursor, &it),
.osc_113 => try parseResetDynamicColor(alloc, .pointer_foreground, &it),
.osc_114 => try parseResetDynamicColor(alloc, .pointer_background, &it),
.osc_115 => try parseResetDynamicColor(alloc, .tektronix_foreground, &it),
.osc_116 => try parseResetDynamicColor(alloc, .tektronix_background, &it),
.osc_117 => try parseResetDynamicColor(alloc, .highlight_background, &it),
.osc_118 => try parseResetDynamicColor(alloc, .tektronix_cursor, &it),
.osc_119 => try parseResetDynamicColor(alloc, .highlight_foreground, &it),
};
}
/// OSC 4/5
fn parseGetSetAnsiColor(
alloc: Allocator,
comptime op: Operation,
it: *std.mem.TokenIterator(u8, .scalar),
) Allocator.Error!List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
var result: List = .{};
errdefer result.deinit(alloc);
while (true) {
// We expect a `c; spec` pair. If either doesn't exist then
// we return the results up to this point.
const color_str = it.next() orelse return result;
const spec_str = it.next() orelse return result;
// Color must be numeric. u9 because that'll fit our palette + special
const color: u9 = std.fmt.parseInt(
u9,
color_str,
10,
) catch return result;
// Parse the color.
const target: Target = switch (op) {
// OSC5 maps directly to the Special enum.
.osc_5 => .{ .special = std.meta.intToEnum(
SpecialColor,
std.math.cast(u3, color) orelse return result,
) catch return result },
// OSC4 maps 0-255 to palette, 256-259 to special offset
// by the palette count.
.osc_4 => if (std.math.cast(u8, color)) |idx| .{
.palette = idx,
} else .{ .special = std.meta.intToEnum(
SpecialColor,
std.math.cast(u3, color - 256) orelse return result,
) catch return result },
else => comptime unreachable,
};
// "?" always results in a query.
if (std.mem.eql(u8, spec_str, "?")) {
const req = try result.addOne(alloc);
req.* = .{ .query = target };
continue;
}
const rgb = RGB.parse(spec_str) catch return result;
const req = try result.addOne(alloc);
req.* = .{ .set = .{
.target = target,
.color = rgb,
} };
}
}
/// OSC 104/105: Reset ANSI Colors
fn parseResetAnsiColor(
alloc: Allocator,
comptime op: Operation,
it: *std.mem.TokenIterator(u8, .scalar),
) Allocator.Error!List {
// Note: xterm stops parsing the reset list on any error, but we're
// more flexible and try the next value. This matches the behavior of
// Kitty and I don't see a downside to being more flexible here. Hopefully
// no one depends on the exact behavior of xterm.
var result: List = .{};
errdefer result.deinit(alloc);
while (true) {
const color_str = it.next() orelse {
// If no parameters are given, we reset the full table.
if (result.count() == 0) {
const req = try result.addOne(alloc);
req.* = switch (op) {
.osc_104 => .reset_palette,
.osc_105 => .reset_special,
else => comptime unreachable,
};
}
return result;
};
// Empty color strings are ignored, not treated as an error.
if (color_str.len == 0) continue;
// Color must be numeric. u9 because that'll fit our palette + special
const color: u9 = std.fmt.parseInt(
u9,
color_str,
10,
) catch continue;
// Parse the color.
const target: Target = switch (op) {
// OSC105 maps directly to the Special enum.
.osc_105 => .{ .special = std.meta.intToEnum(
SpecialColor,
std.math.cast(u3, color) orelse continue,
) catch continue },
// OSC104 maps 0-255 to palette, 256-259 to special offset
// by the palette count.
.osc_104 => if (std.math.cast(u8, color)) |idx| .{
.palette = idx,
} else .{ .special = std.meta.intToEnum(
SpecialColor,
std.math.cast(u3, color - 256) orelse continue,
) catch continue },
else => comptime unreachable,
};
const req = try result.addOne(alloc);
req.* = .{ .reset = target };
}
}
/// OSC 10-19: Get/Set Dynamic Colors
fn parseGetSetDynamicColor(
alloc: Allocator,
start: DynamicColor,
it: *std.mem.TokenIterator(u8, .scalar),
) Allocator.Error!List {
// Note: in ANY error scenario below we return the accumulated results.
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
var result: List = .{};
var color: DynamicColor = start;
while (true) {
const spec_str = it.next() orelse return result;
if (std.mem.eql(u8, spec_str, "?")) {
const req = try result.addOne(alloc);
req.* = .{ .query = .{ .dynamic = color } };
} else {
const rgb = RGB.parse(spec_str) catch return result;
const req = try result.addOne(alloc);
req.* = .{ .set = .{
.target = .{ .dynamic = color },
.color = rgb,
} };
}
// Each successive value uses the next color so long as it exists.
color = color.next() orelse return result;
}
}
/// OSC 110-119: Reset Dynamic Colors
fn parseResetDynamicColor(
alloc: Allocator,
color: DynamicColor,
it: *std.mem.TokenIterator(u8, .scalar),
) Allocator.Error!List {
var result: List = .{};
errdefer result.deinit(alloc);
if (it.next() != null) return result;
const req = try result.addOne(alloc);
req.* = .{ .reset = .{ .dynamic = color } };
return result;
}
/// A segmented list is used to avoid copying when many operations
/// are given in a single OSC. In most cases, OSC 4/104/etc. send
/// very few so the prealloc is optimized for that.
///
/// The exact prealloc value is chosen arbitrarily assuming most
/// color ops have very few. If we can get empirical data on more
/// typical values we can switch to that.
pub const List = std.SegmentedList(
Request,
2,
);
/// A single operation related to the terminal color palette.
pub const Request = union(enum) {
set: ColoredTarget,
query: Target,
reset: Target,
reset_palette,
reset_special,
};
pub const Target = union(enum) {
palette: u8,
special: SpecialColor,
dynamic: DynamicColor,
};
pub const ColoredTarget = struct {
target: Target,
color: RGB,
};
test "osc4" {
const testing = std.testing;
const alloc = testing.allocator;
// Test every palette index
for (0..std.math.maxInt(u8)) |idx| {
// Simple color set
// printf '\e]4;0;red\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};red",
.{idx},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_4, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = @intCast(idx) },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
// Simple color query
// printf '\e]4;0;?\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};?",
.{idx},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_4, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .query = .{ .palette = @intCast(idx) } },
list.at(0).*,
);
}
// Trailing invalid data produces results up to that point
// printf '\e]4;0;red;\e\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};red;",
.{idx},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_4, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = @intCast(idx) },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
// Whitespace doesn't produce a working value in xterm but we
// allow it because Kitty does and it seems harmless.
//
// printf '\e]4;0;red \e\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};red ",
.{idx},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_4, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = @intCast(idx) },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
}
// Test every special color
for (0..@typeInfo(SpecialColor).@"enum".fields.len) |i| {
const special = try std.meta.intToEnum(SpecialColor, i);
// Simple color set
// printf '\e]4;256;red\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};red",
.{256 + i},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_4, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .special = special },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
}
}
test "osc5" {
const testing = std.testing;
const alloc = testing.allocator;
// Test every special color
for (0..@typeInfo(SpecialColor).@"enum".fields.len) |i| {
const special = try std.meta.intToEnum(SpecialColor, i);
// Simple color set
// printf '\e]4;256;red\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d};red",
.{i},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_5, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .special = special },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
}
}
test "osc4: multiple requests" {
const testing = std.testing;
const alloc = testing.allocator;
// printf '\e]4;0;red;1;blue\e\\'
{
var list = try parse(
alloc,
.osc_4,
"0;red;1;blue",
);
defer list.deinit(alloc);
try testing.expectEqual(2, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = 0 },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = 1 },
.color = RGB{ .r = 0, .g = 0, .b = 255 },
} },
list.at(1).*,
);
}
// Multiple requests with same index overwrite each other
// printf '\e]4;0;red;0;blue\e\\'
{
var list = try parse(
alloc,
.osc_4,
"0;red;0;blue",
);
defer list.deinit(alloc);
try testing.expectEqual(2, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = 0 },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
try testing.expectEqual(
Request{ .set = .{
.target = .{ .palette = 0 },
.color = RGB{ .r = 0, .g = 0, .b = 255 },
} },
list.at(1).*,
);
}
}
test "osc104" {
const testing = std.testing;
const alloc = testing.allocator;
// Test every palette index
for (0..std.math.maxInt(u8)) |idx| {
// Simple color set
// printf '\e]104;0\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d}",
.{idx},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_104, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset = .{ .palette = @intCast(idx) } },
list.at(0).*,
);
}
}
// Test every special color
for (0..@typeInfo(SpecialColor).@"enum".fields.len) |i| {
const special = try std.meta.intToEnum(SpecialColor, i);
// Simple color set
// printf '\e]104;256\\'
{
const body = try std.fmt.allocPrint(
alloc,
"{d}",
.{256 + i},
);
defer alloc.free(body);
var list = try parse(alloc, .osc_104, body);
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset = .{ .special = special } },
list.at(0).*,
);
}
}
}
test "osc104 empty index" {
const testing = std.testing;
const alloc = testing.allocator;
var list = try parse(alloc, .osc_104, "0;;1");
defer list.deinit(alloc);
try testing.expectEqual(2, list.count());
try testing.expectEqual(
Request{ .reset = .{ .palette = 0 } },
list.at(0).*,
);
try testing.expectEqual(
Request{ .reset = .{ .palette = 1 } },
list.at(1).*,
);
}
test "osc104 invalid index" {
const testing = std.testing;
const alloc = testing.allocator;
var list = try parse(alloc, .osc_104, "ffff;1");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset = .{ .palette = 1 } },
list.at(0).*,
);
}
test "osc104 reset all" {
const testing = std.testing;
const alloc = testing.allocator;
var list = try parse(alloc, .osc_104, "");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset_palette = {} },
list.at(0).*,
);
}
test "osc105 reset all" {
const testing = std.testing;
const alloc = testing.allocator;
var list = try parse(alloc, .osc_105, "");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset_special = {} },
list.at(0).*,
);
}
// OSC 10-19: Get/Set Dynamic Colors
test "dynamic" {
const testing = std.testing;
const alloc = testing.allocator;
inline for (@typeInfo(DynamicColor).@"enum".fields) |field| {
const color = @field(DynamicColor, field.name);
const op = @field(Operation, std.fmt.comptimePrint(
"osc_{d}",
.{field.value},
));
// Example script:
// printf '\e]10;red\e\\'
{
var list = try parse(alloc, op, "red");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .dynamic = color },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
}
}
}
test "dynamic multiple" {
const testing = std.testing;
const alloc = testing.allocator;
// Example script:
// printf '\e]11;red;blue\e\\'
{
var list = try parse(
alloc,
.osc_11,
"red;blue",
);
defer list.deinit(alloc);
try testing.expectEqual(2, list.count());
try testing.expectEqual(
Request{ .set = .{
.target = .{ .dynamic = .background },
.color = RGB{ .r = 255, .g = 0, .b = 0 },
} },
list.at(0).*,
);
try testing.expectEqual(
Request{ .set = .{
.target = .{ .dynamic = .cursor },
.color = RGB{ .r = 0, .g = 0, .b = 255 },
} },
list.at(1).*,
);
}
}
// OSC 110-119: Reset Dynamic Colors
test "reset dynamic" {
const testing = std.testing;
const alloc = testing.allocator;
inline for (@typeInfo(DynamicColor).@"enum".fields) |field| {
const color = @field(DynamicColor, field.name);
const op = @field(Operation, std.fmt.comptimePrint(
"osc_1{d}",
.{field.value},
));
// Example script:
// printf '\e]110\e\\'
{
var list = try parse(alloc, op, "");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset = .{ .dynamic = color } },
list.at(0).*,
);
}
// xterm allows a trailing semicolon. script to verify:
//
// printf '\e]110;\e\\'
{
var list = try parse(alloc, op, ";");
defer list.deinit(alloc);
try testing.expectEqual(1, list.count());
try testing.expectEqual(
Request{ .reset = .{ .dynamic = color } },
list.at(0).*,
);
}
// xterm does NOT allow any whitespace
//
// printf '\e]110 \e\\'
{
var list = try parse(alloc, op, " ");
defer list.deinit(alloc);
try testing.expectEqual(0, list.count());
}
}
}

View File

@@ -1565,7 +1565,11 @@ pub fn Stream(comptime Handler: type) type {
.color_operation => |v| {
if (@hasDecl(T, "handleColorOperation")) {
try self.handler.handleColorOperation(v.source, &v.operations, v.terminator);
try self.handler.handleColorOperation(
v.op,
&v.requests,
v.terminator,
);
return;
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},

View File

@@ -1187,12 +1187,15 @@ pub const StreamHandler = struct {
pub fn handleColorOperation(
self: *StreamHandler,
source: terminal.osc.Command.ColorOperation.Source,
operations: *const terminal.osc.Command.ColorOperation.List,
op: terminal.osc.color.Operation,
requests: *const terminal.osc.color.List,
terminator: terminal.osc.Terminator,
) !void {
// We'll need op one day if we ever implement reporting special colors.
_ = op;
// return early if there is nothing to do
if (operations.count() == 0) return;
if (requests.count() == 0) return;
var buffer: [1024]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = .init(&buffer);
@@ -1201,63 +1204,71 @@ pub const StreamHandler = struct {
var response: std.ArrayListUnmanaged(u8) = .empty;
const writer = response.writer(alloc);
var report: bool = false;
try writer.print("\x1b]{}", .{source});
var it = operations.constIterator(0);
while (it.next()) |op| {
switch (op.*) {
var it = requests.constIterator(0);
while (it.next()) |req| {
switch (req.*) {
.set => |set| {
switch (set.kind) {
switch (set.target) {
.palette => |i| {
self.terminal.flags.dirty.palette = true;
self.terminal.color_palette.colors[i] = set.color;
self.terminal.color_palette.mask.set(i);
},
.foreground => {
self.foreground_color = set.color;
_ = self.renderer_mailbox.push(.{
.foreground_color = set.color,
}, .{ .forever = {} });
},
.background => {
self.background_color = set.color;
_ = self.renderer_mailbox.push(.{
.background_color = set.color,
}, .{ .forever = {} });
},
.cursor => {
self.cursor_color = set.color;
_ = self.renderer_mailbox.push(.{
.cursor_color = set.color,
}, .{ .forever = {} });
.dynamic => |dynamic| switch (dynamic) {
.foreground => {
self.foreground_color = set.color;
_ = self.renderer_mailbox.push(.{
.foreground_color = set.color,
}, .{ .forever = {} });
},
.background => {
self.background_color = set.color;
_ = self.renderer_mailbox.push(.{
.background_color = set.color,
}, .{ .forever = {} });
},
.cursor => {
self.cursor_color = set.color;
_ = self.renderer_mailbox.push(.{
.cursor_color = set.color,
}, .{ .forever = {} });
},
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> log.info("setting dynamic color {s} not implemented", .{
@tagName(dynamic),
}),
},
.special => log.info("setting special colors not implemented", .{}),
}
// Notify the surface of the color change
self.surfaceMessageWriter(.{ .color_change = .{
.kind = set.kind,
.target = set.target,
.color = set.color,
} });
},
.reset => |kind| {
switch (kind) {
.palette => |i| {
const mask = &self.terminal.color_palette.mask;
self.terminal.flags.dirty.palette = true;
self.terminal.color_palette.colors[i] = self.terminal.default_palette[i];
mask.unset(i);
.reset => |target| switch (target) {
.palette => |i| {
const mask = &self.terminal.color_palette.mask;
self.terminal.flags.dirty.palette = true;
self.terminal.color_palette.colors[i] = self.terminal.default_palette[i];
mask.unset(i);
self.surfaceMessageWriter(.{
.color_change = .{
.kind = .{ .palette = @intCast(i) },
.color = self.terminal.color_palette.colors[i],
},
});
},
self.surfaceMessageWriter(.{
.color_change = .{
.target = target,
.color = self.terminal.color_palette.colors[i],
},
});
},
.dynamic => |dynamic| switch (dynamic) {
.foreground => {
self.foreground_color = null;
_ = self.renderer_mailbox.push(.{
@@ -1265,7 +1276,7 @@ pub const StreamHandler = struct {
}, .{ .forever = {} });
self.surfaceMessageWriter(.{ .color_change = .{
.kind = .foreground,
.target = target,
.color = self.default_foreground_color,
} });
},
@@ -1276,7 +1287,7 @@ pub const StreamHandler = struct {
}, .{ .forever = {} });
self.surfaceMessageWriter(.{ .color_change = .{
.kind = .background,
.target = target,
.color = self.default_background_color,
} });
},
@@ -1289,33 +1300,83 @@ pub const StreamHandler = struct {
if (self.default_cursor_color) |color| {
self.surfaceMessageWriter(.{ .color_change = .{
.kind = .cursor,
.target = target,
.color = color,
} });
}
},
}
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> log.warn("resetting dynamic color {s} not implemented", .{
@tagName(dynamic),
}),
},
.special => log.info("resetting special colors not implemented", .{}),
},
.report => |kind| report: {
if (self.osc_color_report_format == .none) break :report;
.reset_palette => {
const mask = &self.terminal.color_palette.mask;
var mask_iterator = mask.iterator(.{});
while (mask_iterator.next()) |i| {
self.terminal.flags.dirty.palette = true;
self.terminal.color_palette.colors[i] = self.terminal.default_palette[i];
self.surfaceMessageWriter(.{
.color_change = .{
.target = .{ .palette = @intCast(i) },
.color = self.terminal.color_palette.colors[i],
},
});
}
mask.* = .initEmpty();
},
report = true;
.reset_special => log.warn(
"resetting all special colors not implemented",
.{},
),
.query => |kind| report: {
if (self.osc_color_report_format == .none) break :report;
const color = switch (kind) {
.palette => |i| self.terminal.color_palette.colors[i],
.foreground => self.foreground_color orelse self.default_foreground_color,
.background => self.background_color orelse self.default_background_color,
.cursor => self.cursor_color orelse
self.default_cursor_color orelse
self.foreground_color orelse
self.default_foreground_color,
.dynamic => |dynamic| switch (dynamic) {
.foreground => self.foreground_color orelse self.default_foreground_color,
.background => self.background_color orelse self.default_background_color,
.cursor => self.cursor_color orelse
self.default_cursor_color orelse
self.foreground_color orelse
self.default_foreground_color,
.pointer_foreground,
.pointer_background,
.tektronix_foreground,
.tektronix_background,
.highlight_background,
.tektronix_cursor,
.highlight_foreground,
=> {
log.info(
"reporting dynamic color {s} not implemented",
.{@tagName(dynamic)},
);
break :report;
},
},
.special => {
log.info("reporting special colors not implemented", .{});
break :report;
},
};
switch (self.osc_color_report_format) {
.@"16-bit" => switch (kind) {
.palette => |i| try writer.print(
";{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
"\x1b]4;{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.{
i,
@as(u16, color.r) * 257,
@@ -1323,19 +1384,21 @@ pub const StreamHandler = struct {
@as(u16, color.b) * 257,
},
),
else => try writer.print(
";rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.dynamic => |dynamic| try writer.print(
"\x1b]{d};rgb:{x:0>4}/{x:0>4}/{x:0>4}",
.{
@intFromEnum(dynamic),
@as(u16, color.r) * 257,
@as(u16, color.g) * 257,
@as(u16, color.b) * 257,
},
),
.special => unreachable,
},
.@"8-bit" => switch (kind) {
.palette => |i| try writer.print(
";{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
"\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{
i,
@as(u16, color.r),
@@ -1343,25 +1406,29 @@ pub const StreamHandler = struct {
@as(u16, color.b),
},
),
else => try writer.print(
";rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.dynamic => |dynamic| try writer.print(
"\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}",
.{
@intFromEnum(dynamic),
@as(u16, color.r),
@as(u16, color.g),
@as(u16, color.b),
},
),
.special => unreachable,
},
.none => unreachable,
}
try writer.writeAll(terminator.string());
},
}
}
if (report) {
if (response.items.len > 0) {
// If any of the operations were reports, finalize the report
// string and send it to the terminal.
try writer.writeAll(terminator.string());
const msg = try termio.Message.writeReq(self.alloc, response.items);
self.messageWriter(msg);
}