aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/c
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/c
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/c')
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color.zig575
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color_scheme.zig63
-rw-r--r--vendor/libghostty-vt/src/terminal/c/grid_ref.zig17
-rw-r--r--vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig317
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig23
-rw-r--r--vendor/libghostty-vt/src/terminal/c/render.zig173
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig711
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig19
-rw-r--r--vendor/libghostty-vt/src/terminal/c/unicode.zig85
9 files changed, 1900 insertions, 83 deletions
diff --git a/vendor/libghostty-vt/src/terminal/c/color.zig b/vendor/libghostty-vt/src/terminal/c/color.zig
index 3d244a19..ad92883c 100644
--- a/vendor/libghostty-vt/src/terminal/c/color.zig
+++ b/vendor/libghostty-vt/src/terminal/c/color.zig
@@ -1,8 +1,46 @@
+const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
+const x11_color = @import("../x11_color.zig");
+const Result = @import("result.zig").Result;
+
+/// C: GhosttyColorPaletteMask
+pub const PaletteMask = extern struct {
+ bits: [4]u64,
+
+ /// Convert to the Zig PaletteMask (std.StaticBitSet(256)).
+ pub fn toZig(self: *const PaletteMask) color.PaletteMask {
+ var result = color.PaletteMask.initEmpty();
+ for (0..256) |i| {
+ if (((self.bits[i >> 6] >> @as(u6, @intCast(i & 63))) & 1) != 0) {
+ result.set(i);
+ }
+ }
+ return result;
+ }
+};
+
+/// C: GhosttyColorX11Entry
+pub const X11Entry = extern struct {
+ /// Null-terminated color name; null marks the end of the table.
+ name: ?[*:0]const u8,
+ color: color.RGB.C,
+};
+
+/// Comptime-built static table, terminated by a null-name entry.
+const x11_entries: [x11_color.entries.len + 1]X11Entry = entries: {
+ @setEvalBranchQuota(10_000);
+ var result: [x11_color.entries.len + 1]X11Entry = undefined;
+ for (x11_color.entries, 0..) |entry, i| result[i] = .{
+ .name = entry.name.ptr,
+ .color = entry.color.cval(),
+ };
+ result[x11_color.entries.len] = .{ .name = null, .color = .{ .r = 0, .g = 0, .b = 0 } };
+ break :entries result;
+};
pub fn rgb_get(
- c: color.RGB.C,
+ c: *const color.RGB.C,
r: *u8,
g: *u8,
b: *u8,
@@ -11,3 +49,538 @@ pub fn rgb_get(
g.* = c.g;
b.* = c.b;
}
+
+pub fn parse_x11(
+ name_: ?[*]const u8,
+ len: usize,
+ out: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const name = (name_ orelse return .invalid_value)[0..len];
+ const trimmed = std.mem.trim(u8, name, " \t");
+ const rgb = x11_color.map.get(trimmed) orelse return .invalid_value;
+ out.* = rgb.cval();
+ return .success;
+}
+
+pub fn parse(
+ value_: ?[*]const u8,
+ len: usize,
+ out: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const value = (value_ orelse return .invalid_value)[0..len];
+ const rgb = color.RGB.parse(value) catch return .invalid_value;
+ out.* = rgb.cval();
+ return .success;
+}
+
+pub fn palette_default(out: *color.PaletteC) callconv(lib.calling_conv) void {
+ out.* = color.paletteCval(&color.default);
+}
+
+/// Generate a 256-color palette. The output may alias the base input.
+pub fn palette_generate(
+ base_: ?*const color.PaletteC,
+ skip_: ?*const PaletteMask,
+ bg: *const color.RGB.C,
+ fg: *const color.RGB.C,
+ harmonious: bool,
+ out: *color.PaletteC,
+) callconv(lib.calling_conv) void {
+ const base: color.Palette = if (base_) |base|
+ color.paletteZval(base)
+ else
+ color.default;
+ const skip: color.PaletteMask = if (skip_) |skip|
+ skip.toZig()
+ else
+ .initEmpty();
+ const result = color.generate256Color(
+ base,
+ skip,
+ .fromC(bg.*),
+ .fromC(fg.*),
+ harmonious,
+ );
+ out.* = color.paletteCval(&result);
+}
+
+pub fn x11_names() callconv(lib.calling_conv) [*]const X11Entry {
+ return x11_entries[0..].ptr;
+}
+
+pub fn x11_name_count() callconv(lib.calling_conv) usize {
+ return x11_color.entries.len;
+}
+
+pub fn parse_palette_entry(
+ value_: ?[*]const u8,
+ len: usize,
+ out_index: *u8,
+ out_rgb: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const value = (value_ orelse return .invalid_value)[0..len];
+ const entry = color.parsePaletteEntry(value) catch return .invalid_value;
+ out_index.* = entry.index;
+ out_rgb.* = entry.color.cval();
+ return .success;
+}
+
+pub fn luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(c.*).luminance();
+}
+
+pub fn perceived_luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(c.*).perceivedLuminance();
+}
+
+pub fn contrast(a: *const color.RGB.C, b: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(a.*).contrast(.fromC(b.*));
+}
+
+fn expectRgb(expected: color.RGB, actual: color.RGB.C) !void {
+ try std.testing.expectEqual(expected, color.RGB.fromC(actual));
+}
+
+fn expectPaletteC(expected: *const color.PaletteC, actual: *const color.PaletteC) !void {
+ for (expected.*, actual.*) |expected_rgb, actual_rgb| {
+ try expectRgb(color.RGB.fromC(expected_rgb), actual_rgb);
+ }
+}
+
+fn generatePalette(
+ base_: ?*const color.PaletteC,
+ skip_: ?*const PaletteMask,
+ bg: color.RGB,
+ fg: color.RGB,
+ harmonious: bool,
+) color.PaletteC {
+ var out: color.PaletteC = undefined;
+ const bg_c = bg.cval();
+ const fg_c = fg.cval();
+ palette_generate(base_, skip_, &bg_c, &fg_c, harmonious, &out);
+ return out;
+}
+
+fn setMaskBit(mask: *PaletteMask, idx: usize) void {
+ mask.bits[idx >> 6] |= @as(u64, 1) << @as(u6, @intCast(idx & 63));
+}
+
+test "color: parse_x11 valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "white", .expected = .{ .r = 255, .g = 255, .b = 255 } },
+ .{ .input = "medium spring green", .expected = .{ .r = 0, .g = 250, .b = 154 } },
+ .{ .input = "ForestGreen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = "FoReStGReen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = " Forest Green ", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = "\tblack\t", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse_x11(case.input.ptr, case.input.len, &out));
+ try expectRgb(case.expected, out);
+ }
+}
+
+test "color: parse_x11 invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "nosuchcolor",
+ "",
+ " ",
+ "#ffffff",
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_x11(case.ptr, case.len, &out));
+ }
+
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_x11(null, 0, &out));
+}
+
+test "color: parse valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "black", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ .{ .input = "#AABBCC", .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ .{ .input = "0A0B0C", .expected = .{ .r = 10, .g = 11, .b = 12 } },
+ .{ .input = "FFF", .expected = .{ .r = 255, .g = 255, .b = 255 } },
+ .{ .input = "#345", .expected = .{ .r = 51, .g = 68, .b = 85 } },
+ .{ .input = "rgb:1/2/3", .expected = .{ .r = 17, .g = 34, .b = 51 } },
+ .{ .input = " black ", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ .{ .input = " #AABBCC ", .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse(case.input.ptr, case.input.len, &out));
+ try expectRgb(case.expected, out);
+ }
+}
+
+test "color: parse invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "",
+ "notacolor",
+ "#12345",
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse(case.ptr, case.len, &out));
+ }
+
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse(null, 0, &out));
+}
+
+test "color: parse_palette_entry valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ index: u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "0=#AABBCC", .index = 0, .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ .{ .input = "0xF=#ABCDEF", .index = 15, .expected = .{ .r = 171, .g = 205, .b = 239 } },
+ .{ .input = "0b1=#014589", .index = 1, .expected = .{ .r = 1, .g = 69, .b = 137 } },
+ .{ .input = "0o7=#234567", .index = 7, .expected = .{ .r = 35, .g = 69, .b = 103 } },
+ .{ .input = " 1= #DDEEFF ", .index = 1, .expected = .{ .r = 221, .g = 238, .b = 255 } },
+ .{ .input = "1=black", .index = 1, .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ };
+
+ for (cases) |case| {
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse_palette_entry(
+ case.input.ptr,
+ case.input.len,
+ &index,
+ &rgb,
+ ));
+ try testing.expectEqual(case.index, index);
+ try expectRgb(case.expected, rgb);
+ }
+}
+
+test "color: parse_palette_entry invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "256=#AABBCC",
+ "a",
+ "",
+ "1=notacolor",
+ };
+
+ for (cases) |case| {
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_palette_entry(
+ case.ptr,
+ case.len,
+ &index,
+ &rgb,
+ ));
+ }
+
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_palette_entry(null, 0, &index, &rgb));
+}
+
+test "color: palette_default" {
+ var out: color.PaletteC = undefined;
+ palette_default(&out);
+
+ const expected = color.paletteCval(&color.default);
+ try expectPaletteC(&expected, &out);
+
+ try expectRgb(.{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, out[0]);
+ try expectRgb(.{ .r = 0, .g = 0, .b = 0 }, out[16]);
+ try expectRgb(.{ .r = 255, .g = 255, .b = 255 }, out[231]);
+ try expectRgb(.{ .r = 8, .g = 8, .b = 8 }, out[232]);
+ try expectRgb(.{ .r = 238, .g = 238, .b = 238 }, out[255]);
+}
+
+test "color: palette_generate base16 preserved" {
+ const testing = std.testing;
+
+ const base = color.paletteCval(&color.default);
+ const palette = generatePalette(
+ &base,
+ null,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ for (0..16) |i| {
+ try testing.expectEqual(color.RGB.fromC(base[i]), color.RGB.fromC(palette[i]));
+ }
+}
+
+test "color: palette_generate cube corners" {
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const palette = generatePalette(null, null, bg, fg, false);
+
+ try expectRgb(bg, palette[16]);
+ try expectRgb(fg, palette[231]);
+}
+
+test "color: palette_generate light theme harmonious=false" {
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+
+ const normal = generatePalette(null, null, white, black, false);
+ try expectRgb(black, normal[16]);
+ try expectRgb(white, normal[231]);
+
+ const harmonious = generatePalette(null, null, white, black, true);
+ try expectRgb(white, harmonious[16]);
+ try expectRgb(black, harmonious[231]);
+}
+
+test "color: palette_generate grayscale monotonic" {
+ const testing = std.testing;
+
+ const palette = generatePalette(
+ null,
+ null,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ var prev_lum: f64 = 0.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum >= prev_lum);
+ prev_lum = lum;
+ }
+}
+
+test "color: palette_generate skip mask" {
+ const testing = std.testing;
+
+ const base = color.paletteCval(&color.default);
+ var skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+ setMaskBit(&skip, 20);
+ setMaskBit(&skip, 100);
+ setMaskBit(&skip, 240);
+
+ const palette = generatePalette(
+ &base,
+ &skip,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ try testing.expectEqual(color.RGB.fromC(base[20]), color.RGB.fromC(palette[20]));
+ try testing.expectEqual(color.RGB.fromC(base[100]), color.RGB.fromC(palette[100]));
+ try testing.expectEqual(color.RGB.fromC(base[240]), color.RGB.fromC(palette[240]));
+ try testing.expect(!color.RGB.fromC(palette[21]).eql(color.RGB.fromC(base[21])));
+}
+
+test "color: palette_generate dark harmonious no-op" {
+ const testing = std.testing;
+
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const normal = generatePalette(null, null, bg, fg, false);
+ const harmonious = generatePalette(null, null, bg, fg, true);
+
+ for (16..256) |i| {
+ try testing.expectEqual(color.RGB.fromC(normal[i]), color.RGB.fromC(harmonious[i]));
+ }
+}
+
+test "color: palette_generate light harmonious ramp" {
+ const testing = std.testing;
+
+ const bg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const fg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+
+ {
+ const palette = generatePalette(null, null, bg, fg, false);
+ var prev_lum: f64 = 0.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum >= prev_lum);
+ prev_lum = lum;
+ }
+ }
+
+ {
+ const palette = generatePalette(null, null, bg, fg, true);
+ var prev_lum: f64 = 1.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum <= prev_lum);
+ prev_lum = lum;
+ }
+ }
+}
+
+test "color: palette_generate NULL base uses default" {
+ const base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
+ const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
+
+ const null_base = generatePalette(null, null, bg, fg, false);
+ const explicit = generatePalette(&base, null, bg, fg, false);
+ try expectPaletteC(&explicit, &null_base);
+}
+
+test "color: palette_generate NULL skip" {
+ const base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
+ const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
+ const skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+
+ const null_skip = generatePalette(&base, null, bg, fg, false);
+ const explicit = generatePalette(&base, &skip, bg, fg, false);
+ try expectPaletteC(&explicit, &null_skip);
+}
+
+test "color: palette_generate matches generate256Color" {
+ const base_c = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 16, .g = 32, .b = 48 };
+ const fg = color.RGB{ .r = 240, .g = 224, .b = 208 };
+ var skip_c: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+ setMaskBit(&skip_c, 20);
+ setMaskBit(&skip_c, 100);
+ setMaskBit(&skip_c, 240);
+
+ const actual = generatePalette(&base_c, &skip_c, bg, fg, true);
+ const expected_z = color.generate256Color(
+ color.paletteZval(&base_c),
+ skip_c.toZig(),
+ bg,
+ fg,
+ true,
+ );
+ const expected = color.paletteCval(&expected_z);
+ try expectPaletteC(&expected, &actual);
+}
+
+test "color: palette_generate in-place" {
+ var base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const expected = generatePalette(&base, null, bg, fg, false);
+
+ const bg_c = bg.cval();
+ const fg_c = fg.cval();
+ palette_generate(&base, null, &bg_c, &fg_c, false, &base);
+ try expectPaletteC(&expected, &base);
+}
+
+test "color: luminance" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ try testing.expectApproxEqAbs(@as(f64, 0.0), luminance(&black_c), 0.000001);
+ try testing.expectApproxEqAbs(@as(f64, 1.0), luminance(&white_c), 0.000001);
+
+ const samples = [_]color.RGB{
+ .{ .r = 40, .g = 44, .b = 52 },
+ .{ .r = 171, .g = 205, .b = 239 },
+ .{ .r = 12, .g = 34, .b = 56 },
+ };
+ for (samples) |sample| {
+ const sample_c = sample.cval();
+ try testing.expectApproxEqAbs(sample.luminance(), luminance(&sample_c), 0.000001);
+ }
+}
+
+test "color: perceived_luminance" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const dark = color.RGB{ .r = 0x28, .g = 0x2C, .b = 0x34 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ const dark_c = dark.cval();
+ try testing.expectApproxEqAbs(@as(f64, 0.0), perceived_luminance(&black_c), 0.000001);
+ try testing.expectApproxEqAbs(@as(f64, 1.0), perceived_luminance(&white_c), 0.000001);
+ try testing.expect(perceived_luminance(&dark_c) < 0.5);
+ try testing.expect(perceived_luminance(&white_c) > 0.5);
+
+ const samples = [_]color.RGB{
+ dark,
+ .{ .r = 171, .g = 205, .b = 239 },
+ .{ .r = 12, .g = 34, .b = 56 },
+ };
+ for (samples) |sample| {
+ const sample_c = sample.cval();
+ try testing.expectApproxEqAbs(
+ sample.perceivedLuminance(),
+ perceived_luminance(&sample_c),
+ 0.000001,
+ );
+ }
+}
+
+test "color: contrast" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const gray = color.RGB{ .r = 128, .g = 128, .b = 128 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ const gray_c = gray.cval();
+
+ try testing.expectApproxEqAbs(@as(f64, 21.0), contrast(&white_c, &black_c), 0.000001);
+ try testing.expectApproxEqAbs(
+ contrast(&black_c, &white_c),
+ contrast(&white_c, &black_c),
+ 0.000001,
+ );
+ try testing.expectApproxEqAbs(@as(f64, 1.0), contrast(&gray_c, &gray_c), 0.000001);
+}
+
+test "color: x11_names" {
+ const testing = std.testing;
+
+ const names = x11_names();
+ const count = x11_name_count();
+ try testing.expectEqual(x11_color.entries.len, count);
+ try testing.expect(count > 700);
+
+ var walked: usize = 0;
+ while (names[walked].name) |name| : (walked += 1) {
+ const name_slice = std.mem.span(name);
+ const expected = x11_color.map.get(name_slice).?;
+ try testing.expectEqual(expected, color.RGB.fromC(names[walked].color));
+ }
+
+ try testing.expectEqual(count, walked);
+ try testing.expectEqual(@as(?[*:0]const u8, null), names[count].name);
+}
+
+test "color: x11_names static" {
+ try std.testing.expectEqual(@intFromPtr(x11_names()), @intFromPtr(x11_names()));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/color_scheme.zig b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig
new file mode 100644
index 00000000..ee022b35
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig
@@ -0,0 +1,63 @@
+const std = @import("std");
+const lib = @import("../lib.zig");
+const device_status = @import("../device_status.zig");
+const Result = @import("result.zig").Result;
+
+pub fn report_encode(
+ scheme: device_status.ColorScheme,
+ out_: ?[*]u8,
+ out_len: usize,
+ out_written: *usize,
+) callconv(lib.calling_conv) Result {
+ var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{});
+ device_status.encodeColorSchemeReport(&writer, scheme) catch |err| switch (err) {
+ error.WriteFailed => {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ device_status.encodeColorSchemeReport(&discarding.writer, scheme) catch unreachable;
+ out_written.* = @intCast(discarding.count);
+ return .out_of_space;
+ },
+ };
+
+ out_written.* = writer.end;
+ return .success;
+}
+
+test "encode color scheme report dark" {
+ var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.dark, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqualStrings("\x1B[?997;1n", buf[0..written]);
+}
+
+test "encode color scheme report light" {
+ var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.light, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqualStrings("\x1B[?997;2n", buf[0..written]);
+}
+
+test "encode color scheme report with null buffer" {
+ var written: usize = 0;
+ const result = report_encode(.dark, null, 0, &written);
+ try std.testing.expectEqual(.out_of_space, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
+
+test "encode color scheme report with insufficient buffer" {
+ var buf: [3]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.light, &buf, buf.len, &written);
+ try std.testing.expectEqual(.out_of_space, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
+
+test "encode color scheme report with exact buffer" {
+ var buf: [9]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.dark, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
index db7ff2e8..946ca8a1 100644
--- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
+++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
@@ -98,7 +98,8 @@ pub fn grid_ref_hyperlink_uri(
out_len: *usize,
) callconv(lib.calling_conv) Result {
const p = ref.toPin() orelse return .invalid_value;
- const rac = p.node.data.getRowAndCell(p.x, p.y);
+ const terminal_page = p.node.page();
+ const rac = terminal_page.getRowAndCell(p.x, p.y);
const cell = rac.cell;
if (!cell.hyperlink) {
@@ -106,12 +107,15 @@ pub fn grid_ref_hyperlink_uri(
return .success;
}
- const link_id = p.node.data.lookupHyperlink(cell) orelse {
+ const link_id = terminal_page.lookupHyperlink(cell) orelse {
out_len.* = 0;
return .success;
};
- const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id);
- const uri = entry.uri.slice(p.node.data.memory);
+ const entry = terminal_page.hyperlink_set.get(
+ terminal_page.memory,
+ link_id,
+ );
+ const uri = entry.uri.slice(terminal_page.memory);
if (out_buf == null or buf_len < uri.len) {
out_len.* = uri.len;
@@ -133,8 +137,9 @@ pub fn grid_ref_style(
if (cell.style_id == stylepkg.default_id) {
o.* = .fromStyle(.{});
} else {
- o.* = .fromStyle(p.node.data.styles.get(
- p.node.data.memory,
+ const terminal_page = p.node.page();
+ o.* = .fromStyle(terminal_page.styles.get(
+ terminal_page.memory,
cell.style_id,
).*);
}
diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
index a00632ed..13a675cc 100644
--- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
@@ -52,11 +52,13 @@ else
pub const Data = enum(c_int) {
invalid = 0,
placement_iterator = 1,
+ generation = 2,
pub fn OutType(comptime self: Data) type {
return switch (self) {
.invalid => void,
.placement_iterator => PlacementIterator,
+ .generation => u64,
};
}
};
@@ -129,6 +131,7 @@ fn getTyped(
.layer_filter = it.layer_filter,
};
},
+ .generation => out.* = storage.generation,
}
return .success;
}
@@ -178,7 +181,7 @@ pub const ImageData = enum(c_int) {
compression = 6,
data_ptr = 7,
data_len = 8,
- transmit_time_ns = 9,
+ generation = 9,
pub fn OutType(comptime self: ImageData) type {
return switch (self) {
@@ -188,7 +191,7 @@ pub const ImageData = enum(c_int) {
.compression => ImageCompression,
.data_ptr => [*]const u8,
.data_len => usize,
- .transmit_time_ns => u64,
+ .generation => u64,
};
}
};
@@ -260,22 +263,12 @@ fn imageGetTyped(
.compression => out.* = image.compression,
.data_ptr => out.* = image.data.ptr,
.data_len => out.* = image.data.len,
- .transmit_time_ns => out.* = instantNanos(image.transmit_time),
+ .generation => out.* = image.generation,
}
return .success;
}
-/// Flattens an Instant's platform timestamp to nanoseconds for the C API.
-/// The epoch is unspecified; only equality and ordering are meaningful.
-fn instantNanos(instant: std.time.Instant) u64 {
- if (@TypeOf(instant.timestamp) == u64) return instant.timestamp;
- const ts = instant.timestamp;
- const sec: u64 = @intCast(@max(ts.sec, 0));
- const nsec: u64 = @intCast(@max(ts.nsec, 0));
- return sec *| std.time.ns_per_s +| nsec;
-}
-
pub fn placement_iterator_new(
alloc_: ?*const CAllocator,
out: *PlacementIterator,
@@ -990,48 +983,6 @@ test "image_get_handle and image_get with transmitted image" {
try testing.expect(data_len > 0);
}
-test "image_get transmit_time_ns changes on retransmission" {
- if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
-
- var t: terminal_c.Terminal = null;
- try testing.expectEqual(Result.success, terminal_c.new(
- &lib.alloc.test_allocator,
- &t,
- .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
- ));
- defer terminal_c.free(t);
-
- const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
- terminal_c.vt_write(t, cmd.ptr, cmd.len);
-
- var graphics: KittyGraphics = undefined;
- try testing.expectEqual(Result.success, terminal_c.get(
- t,
- .kitty_graphics,
- @ptrCast(&graphics),
- ));
-
- var first: u64 = undefined;
- try testing.expectEqual(Result.success, image_get(
- image_get_handle(graphics, 1),
- .transmit_time_ns,
- @ptrCast(&first),
- ));
- try testing.expect(first > 0);
-
- std.Thread.sleep(1 * std.time.ns_per_ms);
- const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
- terminal_c.vt_write(t, retransmit.ptr, retransmit.len);
-
- var second: u64 = undefined;
- try testing.expectEqual(Result.success, image_get(
- image_get_handle(graphics, 1),
- .transmit_time_ns,
- @ptrCast(&second),
- ));
- try testing.expect(second > first);
-}
-
test "placement_rect with transmit and display" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
@@ -1763,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" {
var values = [_]?*anyopaque{@ptrCast(&id)};
try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null));
}
+
+test "storage generation via get" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ // Fresh storage: generation zero.
+ var gen0: u64 = 99;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0)));
+ try testing.expectEqual(0, gen0);
+
+ // Transmit bumps the generation.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen1: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1)));
+ try testing.expect(gen1 > gen0);
+
+ // Unrelated terminal writes (plain text) do not bump it.
+ const text = "hello world";
+ terminal_c.vt_write(t, text.ptr, text.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen2: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2)));
+ try testing.expectEqual(gen1, gen2);
+
+ // Placement bumps it.
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\";
+ terminal_c.vt_write(t, display.ptr, display.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen3: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3)));
+ try testing.expect(gen3 > gen2);
+
+ // Delete bumps it.
+ const del = "\x1b_Ga=d,d=A\x1b\\";
+ terminal_c.vt_write(t, del.ptr, del.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen4: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4)));
+ try testing.expect(gen4 > gen3);
+}
+
+test "image generation detects same-sized retransmission" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit a 1x2 RGB image with id=1.
+ const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit1.ptr, transmit1.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ var gen1: u64 = 0;
+ var w1: u32 = 0;
+ var h1: u32 = 0;
+ var len1: usize = 0;
+ {
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+ try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1)));
+ try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1)));
+ try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1)));
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1)));
+ try testing.expect(gen1 > 0);
+ }
+
+ // Retransmit the same ID with identical dimensions but different
+ // pixel bytes. All size heuristics match; only the generation
+ // reveals the change.
+ const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
+ terminal_c.vt_write(t, transmit2.ptr, transmit2.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ {
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+ var gen2: u64 = 0;
+ var w2: u32 = 0;
+ var h2: u32 = 0;
+ var len2: usize = 0;
+ try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2)));
+ try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2)));
+ try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2)));
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2)));
+
+ // Size heuristics are identical...
+ try testing.expectEqual(w1, w2);
+ try testing.expectEqual(h1, h2);
+ try testing.expectEqual(len1, len2);
+
+ // ...but the generation changed.
+ try testing.expect(gen2 > gen1);
+ }
+}
+
+test "image generation via image_get_multi" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var id: u32 = 0;
+ var generation: u64 = 0;
+ var written: usize = 0;
+ const keys = [_]ImageData{ .id, .generation };
+ var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) };
+ try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(1, id);
+ try testing.expect(generation > 0);
+
+ // The image stamp came from the same sequence as (and here, the
+ // same event as) the storage stamp.
+ var storage_gen: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen)));
+ try testing.expectEqual(storage_gen, generation);
+}
+
+test "image compression and format always report decoded data" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit a zlib-compressed 1x1 RGB image (o=z). The payload is
+ // base64(zlib([0xFF, 0x00, 0x00])).
+ const compressed = comptime blk: {
+ // zlib stream for the 3 bytes FF 00 00 (stored block).
+ // 78 01: zlib header; 01: final stored block; 03 00 len;
+ // FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian).
+ const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 };
+ var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined;
+ const enc = std.base64.standard.Encoder.encode(&buf, &raw);
+ break :blk enc[0..enc.len].*;
+ };
+ const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ // Compression must report NONE: the data was inflated at
+ // transmission time.
+ var comp: ImageCompression = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp)));
+ try testing.expectEqual(.none, comp);
+
+ // Data is the decoded pixels: width * height * bpp.
+ var data_len: usize = 0;
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len)));
+ try testing.expectEqual(3, data_len);
+
+ var data_ptr: [*]const u8 = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr)));
+ try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]);
+}
+
+test "generation never recurs across resets and screen switches" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ var gen: u64 = 0;
+
+ // Transmit on the main screen.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ const gen_main = gen;
+ try testing.expect(gen_main > 0);
+
+ // Switch to the alternate screen: its storage is untouched, so its
+ // generation is zero (which always means "empty").
+ const alt_on = "\x1b[?1049h";
+ terminal_c.vt_write(t, alt_on.ptr, alt_on.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(0, gen);
+
+ // A transmit on the alt screen draws from the same global sequence,
+ // so its stamp is strictly greater than anything seen on the main
+ // screen: an embedder keying a cache on the generation value alone
+ // can never confuse the two storages.
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ const gen_alt = gen;
+ try testing.expect(gen_alt > gen_main);
+
+ // Back to the main screen: its generation is unchanged.
+ const alt_off = "\x1b[?1049l";
+ terminal_c.vt_write(t, alt_off.ptr, alt_off.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(gen_main, gen);
+
+ // Reset zeroes the storage (empty), and the next mutation continues
+ // from the global sequence: past values are never reused for
+ // different content.
+ terminal_c.reset(t);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(0, gen);
+
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expect(gen > gen_alt);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
index 648bdbe5..37dc5768 100644
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
+++ b/vendor/libghostty-vt/src/terminal/c/main.zig
@@ -5,6 +5,7 @@ const buildpkg = @import("build_info.zig");
pub const allocator = @import("allocator.zig");
pub const cell = @import("cell.zig");
pub const color = @import("color.zig");
+pub const color_scheme = @import("color_scheme.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const grid_ref = @import("grid_ref.zig");
@@ -43,6 +44,7 @@ pub const size_report = @import("size_report.zig");
pub const style = @import("style.zig");
pub const sys = @import("sys.zig");
pub const terminal = @import("terminal.zig");
+pub const unicode = @import("unicode.zig");
// The full C API, unexported.
pub const build_info = buildpkg.get;
@@ -56,6 +58,18 @@ pub const osc_command_type = osc.commandType;
pub const osc_command_data = osc.commandData;
pub const color_rgb_get = color.rgb_get;
+pub const color_contrast = color.contrast;
+pub const color_luminance = color.luminance;
+pub const color_parse = color.parse;
+pub const color_parse_palette_entry = color.parse_palette_entry;
+pub const color_parse_x11 = color.parse_x11;
+pub const color_palette_default = color.palette_default;
+pub const color_palette_generate = color.palette_generate;
+pub const color_perceived_luminance = color.perceived_luminance;
+pub const color_x11_name_count = color.x11_name_count;
+pub const color_x11_names = color.x11_names;
+
+pub const color_scheme_report_encode = color_scheme.report_encode;
pub const focus_encode = focus.encode;
@@ -69,6 +83,8 @@ pub const formatter_free = formatter.free;
pub const render_state_new = render.new;
pub const render_state_free = render.free;
pub const render_state_update = render.update;
+pub const render_state_begin_update = render.begin_update;
+pub const render_state_end_update = render.end_update;
pub const render_state_get = render.get;
pub const render_state_get_multi = render.get_multi;
pub const render_state_set = render.set;
@@ -167,6 +183,8 @@ pub const terminal_resize = terminal.resize;
pub const terminal_set = terminal.set;
pub const terminal_vt_write = terminal.vt_write;
pub const terminal_scroll_viewport = terminal.scroll_viewport;
+pub const terminal_compression_activity = terminal.compression_activity;
+pub const terminal_compress = terminal.compress;
pub const terminal_mode_get = terminal.mode_get;
pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
@@ -198,6 +216,9 @@ pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref;
pub const type_json = types.get_json;
+pub const unicode_codepoint_width = unicode.codepoint_width;
+pub const unicode_grapheme_width = unicode.grapheme_width;
+
pub const grid_ref_cell = grid_ref.grid_ref_cell;
pub const grid_ref_row = grid_ref.grid_ref_row;
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
@@ -214,6 +235,7 @@ test {
_ = buildpkg;
_ = cell;
_ = color;
+ _ = color_scheme;
_ = grid_ref;
_ = grid_ref_tracked;
_ = kitty_graphics;
@@ -236,6 +258,7 @@ test {
_ = sys;
_ = terminal;
_ = types;
+ _ = unicode;
// We want to make sure we run the tests for the C allocator interface.
_ = @import("../../lib/allocator.zig");
diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig
index 0f3b2e78..6cf78ec5 100644
--- a/vendor/libghostty-vt/src/terminal/c/render.zig
+++ b/vendor/libghostty-vt/src/terminal/c/render.zig
@@ -188,6 +188,25 @@ pub fn update(
return .success;
}
+pub fn begin_update(
+ state_: RenderState,
+ terminal_: terminal_c.Terminal,
+) callconv(lib.calling_conv) Result {
+ const state = state_ orelse return .invalid_value;
+ const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
+
+ state.state.beginUpdate(state.alloc, t) catch return .out_of_memory;
+ return .success;
+}
+
+pub fn end_update(
+ state_: RenderState,
+) callconv(lib.calling_conv) Result {
+ const state = state_ orelse return .invalid_value;
+ state.state.endUpdate();
+ return .success;
+}
+
pub fn get(
state_: RenderState,
data: Data,
@@ -591,7 +610,7 @@ fn rowCellsGetGraphemesUtf8(
if (!cell.hasText()) return .success;
- var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
+ var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
return .invalid_value;
for (extra) |cp| {
needed += std.unicode.utf8CodepointSequenceLength(cp) catch
@@ -787,6 +806,61 @@ test "render: update invalid value" {
try testing.expectEqual(Result.invalid_value, update(state, null));
}
+test "render: begin/end update invalid value" {
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.invalid_value, begin_update(null, null));
+ try testing.expectEqual(Result.invalid_value, begin_update(state, null));
+ try testing.expectEqual(Result.invalid_value, end_update(null));
+
+ // End without a begin is safe.
+ try testing.expectEqual(Result.success, end_update(state));
+}
+
+test "render: begin/end update" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{
+ .cols = 10,
+ .rows = 3,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer terminal_c.free(terminal);
+
+ // Write some styled text so that the update has deferred work.
+ const t = terminal.?.terminal;
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("\x1b[1mAB"); // Bold
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ // Begin should record pending work, end should complete it.
+ try testing.expectEqual(Result.success, begin_update(state, terminal));
+ try testing.expect(state.?.state.pending_styles.items.len > 0);
+ try testing.expectEqual(Result.success, end_update(state));
+ try testing.expectEqual(0, state.?.state.pending_styles.items.len);
+
+ // The cell styles should be complete.
+ const row_data = state.?.state.row_data.slice();
+ const cells = row_data.items(.cells);
+ try testing.expect(cells[0].get(0).style.flags.bold);
+ try testing.expect(cells[0].get(1).style.flags.bold);
+}
+
test "render: get invalid value" {
var cols: size.CellCountInt = 0;
try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols)));
@@ -1293,6 +1367,82 @@ test "render: row cells get has_styling" {
}
test "render: row cells get graphemes utf8" {
+ const cases = [_]struct {
+ terminal_input: []const u8,
+ expected: []const u8,
+ }{
+ .{
+ .terminal_input = "e\u{301}",
+ .expected = "e\u{301}",
+ },
+ .{
+ .terminal_input = "\x1b[?2027h\u{1F1FA}\u{1F1F8}",
+ .expected = "\u{1F1FA}\u{1F1F8}",
+ },
+ .{
+ .terminal_input = "\x1b[?2027h\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
+ .expected = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
+ },
+ };
+
+ for (cases) |case| {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ terminal_c.vt_write(terminal, case.terminal_input.ptr, case.terminal_input.len);
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var it: RowIterator = null;
+ try testing.expectEqual(Result.success, row_iterator_new(
+ &lib.alloc.test_allocator,
+ &it,
+ ));
+ defer row_iterator_free(it);
+
+ var cells: RowCells = null;
+ try testing.expectEqual(Result.success, row_cells_new(
+ &lib.alloc.test_allocator,
+ &cells,
+ ));
+ defer row_cells_free(cells);
+
+ try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
+ try testing.expect(row_iterator_next(it));
+ try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 0));
+
+ var text: lib.Buffer = .{};
+ try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+
+ var small = [_]u8{'x'} ** 32;
+ const small_cap = case.expected.len - 1;
+ text = .{ .ptr = small[0..small_cap].ptr, .cap = small_cap };
+ try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+ try testing.expectEqualSlices(u8, &([_]u8{'x'} ** 32), &small);
+
+ var buf: [32]u8 = undefined;
+ text = .{ .ptr = &buf, .cap = case.expected.len };
+ try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+ try testing.expectEqualStrings(case.expected, buf[0..text.len]);
+ }
+
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
@@ -1331,26 +1481,9 @@ test "render: row cells get graphemes utf8" {
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
- try testing.expectEqual(Result.success, row_cells_select(cells, 0));
-
- var text: lib.Buffer = .{};
- try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(@as(usize, input.len), text.len);
-
- var small = [_]u8{ 'x', 'x' };
- text = .{ .ptr = &small, .cap = small.len };
- try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(@as(usize, input.len), text.len);
- try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small);
-
- var buf: [8]u8 = undefined;
- text = .{ .ptr = &buf, .cap = buf.len };
- try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(input.len, text.len);
- try testing.expectEqualStrings(input, buf[0..text.len]);
-
try testing.expectEqual(Result.success, row_cells_select(cells, 1));
- text = .{ .ptr = &buf, .cap = buf.len };
+ var buf: [8]u8 = undefined;
+ var text: lib.Buffer = .{ .ptr = &buf, .cap = buf.len };
try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(@as(usize, 0), text.len);
}
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index a7ad35a6..a896c0fd 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -5,6 +5,7 @@ const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
pub const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
+const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const apc = @import("../apc.zig");
@@ -23,6 +24,7 @@ const grid_ref_tracked_c = @import("grid_ref_tracked.zig");
const selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
+const clipboard = @import("../clipboard.zig");
const Result = @import("result.zig").Result;
const Handler = @import("../stream_terminal.zig").Handler;
@@ -39,6 +41,24 @@ const TerminalWrapper = struct {
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
};
+/// A single MIME representation in a clipboard write.
+///
+/// C: GhosttyClipboardContent
+pub const ClipboardContent = extern struct {
+ mime: lib.String,
+ data: lib.String,
+};
+
+/// A protocol-neutral request to replace or clear clipboard contents.
+///
+/// C: GhosttyClipboardWrite
+pub const ClipboardWrite = extern struct {
+ size: usize,
+ location: clipboard.Location,
+ contents: ?[*]const ClipboardContent,
+ contents_len: usize,
+};
+
/// C callback state for terminal effects. Trampolines are always
/// installed on the stream handler; they check these fields and
/// no-op when the corresponding callback is null.
@@ -51,7 +71,9 @@ const Effects = struct {
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
title_changed: ?TitleChangedFn = null,
+ pwd_changed: ?PwdChangedFn = null,
size_cb: ?SizeFn = null,
+ clipboard_write: ?ClipboardWriteFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
/// trampoline converts C feature codes into this buffer and returns
@@ -82,9 +104,16 @@ const Effects = struct {
/// (len=0) causes the default "libghostty" to be reported.
pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
+ /// C function pointer type for the clipboard_write callback. The request
+ /// and its contents are borrowed and only valid for the callback duration.
+ pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
+
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
+ /// C function pointer type for the pwd_changed callback.
+ pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
+
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
@@ -133,6 +162,44 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
+ fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
+ const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
+ const func = wrapper.effects.clipboard_write orelse return .unsupported;
+
+ // Most protocols currently produce one representation, so keep that
+ // path allocation-free while supporting arbitrary multi-MIME writes.
+ var stack_contents: [4]ClipboardContent = undefined;
+ const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len)
+ stack_contents[0..write.contents.len]
+ else
+ wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch
+ return .io_error;
+ defer if (write.contents.len > stack_contents.len)
+ wrapper.terminal.gpa().free(contents);
+
+ for (contents, write.contents) |*c_content, content| {
+ c_content.* = .{
+ .mime = .{
+ .ptr = content.mime.ptr,
+ .len = content.mime.len,
+ },
+ .data = .{
+ .ptr = content.data.ptr,
+ .len = content.data.len,
+ },
+ };
+ }
+
+ const request: ClipboardWrite = .{
+ .size = @sizeOf(ClipboardWrite),
+ .location = write.location,
+ .contents = if (contents.len > 0) contents.ptr else null,
+ .contents_len = contents.len,
+ };
+ return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
+ }
+
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -198,6 +265,13 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
+ fn pwdChangedTrampoline(handler: *Handler) void {
+ const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
+ const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
+ const func = wrapper.effects.pwd_changed orelse return;
+ func(@ptrCast(wrapper), wrapper.effects.userdata);
+ }
+
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -211,6 +285,12 @@ const Effects = struct {
/// C: GhosttyTerminal
pub const Terminal = ?*TerminalWrapper;
+/// C: GhosttyTerminalCompressionMode
+pub const CompressionMode = ZigTerminal.CompressionMode;
+
+/// C: GhosttyTerminalCompressionResult
+pub const CompressionResult = ZigTerminal.CompressionResult;
+
pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
return (terminal_ orelse return null).terminal;
}
@@ -283,7 +363,9 @@ fn new_(
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,
.title_changed = &Effects.titleChangedTrampoline,
+ .pwd_changed = &Effects.pwdChangedTrampoline,
.size = &Effects.sizeTrampoline,
+ .clipboard_write = &Effects.clipboardWriteTrampoline,
};
wrapper.* = .{
@@ -303,6 +385,30 @@ pub fn vt_write(
wrapper.stream.nextSlice(ptr[0..len]);
}
+pub fn compression_activity(
+ terminal_: Terminal,
+ out_activity_: ?*u64,
+) callconv(lib.calling_conv) Result {
+ const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
+ const out_activity = out_activity_ orelse return .invalid_value;
+ out_activity.* = t.compressionActivity();
+ return .success;
+}
+
+pub fn compress(
+ terminal_: Terminal,
+ mode_: c_int,
+ out_result_: ?*CompressionResult,
+) callconv(lib.calling_conv) Result {
+ const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
+ const out_result = out_result_ orelse return .invalid_value;
+ const mode = std.meta.intToEnum(CompressionMode, mode_) catch
+ return .invalid_value;
+
+ out_result.* = t.compress(mode);
+ return .success;
+}
+
/// C: GhosttyTerminalOption
pub const Option = enum(c_int) {
userdata = 0,
@@ -327,6 +433,11 @@ pub const Option = enum(c_int) {
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
selection = 21,
+ default_cursor_style = 22,
+ default_cursor_blink = 23,
+ glyph_protocol = 24,
+ pwd_changed = 25,
+ clipboard_write = 26,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -339,7 +450,9 @@ pub const Option = enum(c_int) {
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
.title_changed => ?Effects.TitleChangedFn,
+ .pwd_changed => ?Effects.PwdChangedFn,
.size_cb => ?Effects.SizeFn,
+ .clipboard_write => ?Effects.ClipboardWriteFn,
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
@@ -347,9 +460,12 @@ pub const Option = enum(c_int) {
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
+ .glyph_protocol,
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
.selection => ?*const selection_c.CSelection,
+ .default_cursor_style => ?*const TerminalCursorStyle,
+ .default_cursor_blink => ?*const bool,
};
}
};
@@ -391,7 +507,9 @@ fn setTyped(
.enquiry => wrapper.effects.enquiry = value,
.xtversion => wrapper.effects.xtversion = value,
.title_changed => wrapper.effects.title_changed = value,
+ .pwd_changed => wrapper.effects.pwd_changed = value,
.size_cb => wrapper.effects.size_cb = value,
+ .clipboard_write => wrapper.effects.clipboard_write = value,
.title => {
const str = if (value) |v| v.ptr[0..v.len] else "";
wrapper.terminal.setTitle(str) catch return .out_of_memory;
@@ -457,6 +575,11 @@ fn setTyped(
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
+ .glyph_protocol => {
+ const enabled = (value orelse return .success).*;
+ wrapper.stream.handler.apc_handler.enable(.glyph, enabled);
+ if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa());
+ },
.selection => {
if (value) |ptr| {
const sel = ptr.toZig() orelse return .invalid_value;
@@ -465,10 +588,43 @@ fn setTyped(
wrapper.terminal.screens.active.clearSelection();
}
},
+ .default_cursor_style => {
+ const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value;
+ wrapper.stream.handler.default_cursor_style = style;
+ if (wrapper.stream.handler.default_cursor) {
+ wrapper.terminal.screens.active.cursor.cursor_style = style;
+ }
+ },
+ .default_cursor_blink => {
+ const blink = if (value) |ptr| ptr.* else false;
+ wrapper.stream.handler.default_cursor_blink = blink;
+ if (wrapper.stream.handler.default_cursor) {
+ wrapper.terminal.modes.set(.cursor_blinking, blink);
+ }
+ },
}
return .success;
}
+/// C: GhosttyTerminalCursorStyle
+pub const TerminalCursorStyle = enum(c_int) {
+ bar = 0,
+ block = 1,
+ underline = 2,
+ block_hollow = 3,
+ _,
+
+ fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle {
+ return switch (self) {
+ .bar => .bar,
+ .block => .block,
+ .underline => .underline,
+ .block_hollow => .block_hollow,
+ _ => null,
+ };
+ }
+};
+
/// C: GhosttyDeviceAttributes
pub const DeviceAttributes = Effects.CDeviceAttributes;
@@ -484,6 +640,7 @@ pub fn scroll_viewport(
.top => .top,
.bottom => .bottom,
.delta => .{ .delta = behavior.value.delta },
+ .row => .{ .row = behavior.value.row },
});
}
@@ -938,8 +1095,211 @@ test "scroll_viewport" {
}
}
+test "scroll_viewport row" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 5,
+ .rows = 2,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer free(t);
+
+ const zt = t.?.terminal;
+
+ // Write 4 rows so that rows "1" and "2" are pushed into scrollback:
+ // total rows is 4, viewport length is 2.
+ vt_write(t, "1\r\n2\r\n3\r\n4", 10);
+
+ var viewport_active: bool = false;
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+
+ // Row 0 is the top of the scrollback.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(!viewport_active);
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1\n2", str);
+ }
+
+ // An absolute row within the scrollback becomes the first visible
+ // row and round-trips through the scrollbar offset.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } });
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3", str);
+ }
+ var scrollbar_data: TerminalScrollbar = undefined;
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 4), scrollbar_data.total);
+ try testing.expectEqual(@as(u64, 1), scrollbar_data.offset);
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
+
+ // A row past the end clamps to the active area.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("3\n4", str);
+ }
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.offset);
+}
+
+test "scroll_viewport row alt screen" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 5,
+ .rows = 2,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer free(t);
+
+ // Enter the alternate screen, which has no scrollback.
+ vt_write(t, "\x1b[?1049h", 8);
+ var screen: TerminalScreen = undefined;
+ try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen)));
+ try testing.expectEqual(TerminalScreen.alternate, screen);
+
+ // Scrolling to any row keeps the viewport on the active area.
+ var viewport_active: bool = false;
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+
+ // With no scrollback the scrollbar covers exactly the active area.
+ var scrollbar_data: TerminalScrollbar = undefined;
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.total);
+ try testing.expectEqual(@as(u64, 0), scrollbar_data.offset);
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
+}
+
test "scroll_viewport null" {
scroll_viewport(null, .{ .tag = .top, .value = undefined });
+ scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } });
+}
+
+test "compression invalid arguments" {
+ var activity: u64 = undefined;
+ var compression_result: CompressionResult = undefined;
+
+ try testing.expectEqual(
+ Result.invalid_value,
+ compression_activity(null, &activity),
+ );
+ try testing.expectEqual(
+ Result.invalid_value,
+ compress(null, @intFromEnum(CompressionMode.incremental), &compression_result),
+ );
+
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 10_000_000,
+ },
+ ));
+ defer free(t);
+
+ try testing.expectEqual(
+ Result.invalid_value,
+ compression_activity(t, null),
+ );
+ try testing.expectEqual(
+ Result.invalid_value,
+ compress(t, @intFromEnum(CompressionMode.incremental), null),
+ );
+ try testing.expectEqual(
+ Result.invalid_value,
+ compress(t, -1, &compression_result),
+ );
+}
+
+test "compression activity and incremental scheduling" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 10_000_000,
+ },
+ ));
+ defer free(t);
+
+ var initial_activity: u64 = undefined;
+ try testing.expectEqual(
+ Result.success,
+ compression_activity(t, &initial_activity),
+ );
+
+ const line = "repeated and compressible terminal history\r\n";
+ const repeat = 4_000;
+ const input = try testing.allocator.alloc(u8, line.len * repeat);
+ defer testing.allocator.free(input);
+ for (0..repeat) |i|
+ @memcpy(input[i * line.len ..][0..line.len], line);
+ vt_write(t, input.ptr, input.len);
+
+ var activity: u64 = undefined;
+ try testing.expectEqual(
+ Result.success,
+ compression_activity(t, &activity),
+ );
+ try testing.expect(activity != initial_activity);
+
+ var compression_result: CompressionResult = undefined;
+ for (0..1_000) |_| {
+ try testing.expectEqual(
+ Result.success,
+ compress(
+ t,
+ @intFromEnum(CompressionMode.incremental),
+ &compression_result,
+ ),
+ );
+
+ switch (compression_result) {
+ .pending => continue,
+ .complete => break,
+ .unsupported => unreachable,
+ }
+ } else return error.TestUnexpectedResult;
+
+ // Compression changes storage representation, not the activity token.
+ var final_activity: u64 = undefined;
+ try testing.expectEqual(
+ Result.success,
+ compression_activity(t, &final_activity),
+ );
+ try testing.expectEqual(activity, final_activity);
+
+ try testing.expectEqual(
+ Result.success,
+ compress(t, @intFromEnum(CompressionMode.full), &compression_result),
+ );
+ try testing.expectEqual(CompressionResult.complete, compression_result);
}
test "reset" {
@@ -1019,11 +1379,12 @@ test "resize shrinks both axes with cursor at bottom" {
));
defer free(t);
- // CSI 24;1H parks the cursor on the bottom row.
+ // CSI 24;1H -> park the cursor on the bottom row (1-based).
const move = "\x1b[24;1H";
vt_write(t, move, move.len);
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
+ // Previously this underflowed in PageList.resizeCols.
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
try testing.expectEqual(79, t.?.terminal.cols);
try testing.expectEqual(23, t.?.terminal.rows);
@@ -1401,6 +1762,45 @@ test "get invalid" {
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
+test "set default cursor style and blink" {
+ 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);
+
+ var default_style: TerminalCursorStyle = .bar;
+ var default_blink = true;
+ try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
+ try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink)));
+
+ // Setting defaults applies them immediately while the cursor is still default.
+ try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
+
+ // An explicit DECSCUSR style overrides the configured defaults.
+ vt_write(t, "\x1b[2 q", 5);
+ try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
+
+ // Changing defaults does not override an explicit cursor style.
+ default_style = .underline;
+ try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
+ try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
+
+ // DECSCUSR reset restores the configured default style and blink.
+ vt_write(t, "\x1b[0 q", 5);
+ try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
+}
+
test "set and get selection" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -1982,6 +2382,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(
@@ -2279,6 +2719,248 @@ test "title_changed without callback is silent" {
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
+test "set pwd_changed callback" {
+ 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 pwd_count: usize = 0;
+ var last_userdata: ?*anyopaque = null;
+
+ fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
+ pwd_count += 1;
+ last_userdata = ud;
+ }
+ };
+ S.pwd_count = 0;
+ S.last_userdata = null;
+
+ var sentinel: u8 = 88;
+ try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
+ try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged)));
+
+ // OSC 7 ; file:///tmp ST — report pwd
+ const seq1 = "\x1B]7;file:///tmp\x1B\\";
+ vt_write(t, seq1, seq1.len);
+ try testing.expectEqual(@as(usize, 1), S.pwd_count);
+ try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
+ try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
+
+ // Another pwd change
+ const seq2 = "\x1B]7;file:///home/user\x1B\\";
+ vt_write(t, seq2, seq2.len);
+ try testing.expectEqual(@as(usize, 2), S.pwd_count);
+ try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
+}
+
+test "set clipboard_write callback" {
+ 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 count: usize = 0;
+ var last_terminal: Terminal = null;
+ var last_userdata: ?*anyopaque = null;
+ var last_size: usize = 0;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_null: bool = false;
+ var last_contents_len: usize = 0;
+ var last_mimes: [8][64]u8 = undefined;
+ var last_mime_lens: [8]usize = @splat(0);
+ var last_data: [8][64]u8 = undefined;
+ var last_data_lens: [8]usize = @splat(0);
+ var next_result: clipboard.WriteResult = .success;
+
+ fn clipboardWrite(
+ terminal_: Terminal,
+ ud: ?*anyopaque,
+ request: *const ClipboardWrite,
+ ) callconv(lib.calling_conv) clipboard.WriteResult {
+ count += 1;
+ last_terminal = terminal_;
+ last_userdata = ud;
+ last_size = request.size;
+ last_location = request.location;
+ last_contents_null = request.contents == null;
+ last_contents_len = request.contents_len;
+
+ if (request.contents) |ptr| {
+ for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
+ @memcpy(
+ last_mimes[i][0..last_mime_lens[i]],
+ content.mime.ptr[0..last_mime_lens[i]],
+ );
+
+ last_data_lens[i] = @min(content.data.len, last_data[i].len);
+ @memcpy(
+ last_data[i][0..last_data_lens[i]],
+ content.data.ptr[0..last_data_lens[i]],
+ );
+ }
+ }
+
+ return next_result;
+ }
+ };
+ S.count = 0;
+ S.last_terminal = null;
+ S.last_userdata = null;
+ S.next_result = .denied;
+
+ var sentinel: u8 = 88;
+ try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
+
+ // Split OSC 52 write whose decoded payload contains an embedded NUL.
+ const seq1_a = "\x1B]52;c;aGVs";
+ const seq1_b = "bG8Ad29ybGQ=\x1B\\";
+ vt_write(t, seq1_a, seq1_a.len);
+ vt_write(t, seq1_b, seq1_b.len);
+ try testing.expectEqual(@as(usize, 1), S.count);
+ try testing.expectEqual(t, S.last_terminal);
+ try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
+ try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expect(!S.last_contents_null);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
+ try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]);
+
+ // OSC 52 destinations are normalized rather than exposed as wire bytes.
+ const location_cases = [_]struct {
+ selector: u8,
+ expected: clipboard.Location,
+ }{
+ .{ .selector = 's', .expected = .selection },
+ .{ .selector = 'p', .expected = .primary },
+ .{ .selector = 'q', .expected = .standard },
+ };
+ for (location_cases) |case| {
+ const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' };
+ vt_write(t, &seq, seq.len);
+ try testing.expectEqual(case.expected, S.last_location);
+ }
+ try testing.expectEqual(@as(usize, 4), S.count);
+
+ // An empty content list is a clear and retains a null descriptor pointer.
+ const clear = "\x1B]52;s;\x1B\\";
+ vt_write(t, clear, clear.len);
+ try testing.expectEqual(@as(usize, 5), S.count);
+ try testing.expectEqual(clipboard.Location.selection, S.last_location);
+ try testing.expect(S.last_contents_null);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+
+ // Read requests and malformed base64 must never reach the callback.
+ const read = "\x1B]52;c;?\x1B\\";
+ vt_write(t, read, read.len);
+ const malformed = "\x1B]52;c;%%%\x1B\\";
+ vt_write(t, malformed, malformed.len);
+ try testing.expectEqual(@as(usize, 5), S.count);
+
+ // iTerm2 Copy reaches the same normalized callback.
+ const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\";
+ vt_write(t, iterm, iterm.len);
+ try testing.expectEqual(@as(usize, 6), S.count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
+ try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]);
+
+ // Every representation is converted, and callback results propagate
+ // through the C trampoline for protocols that can acknowledge writes.
+ const internal_contents = [_]clipboard.Content{
+ .{ .mime = "text/plain", .data = "plain" },
+ .{ .mime = "application/octet-stream", .data = "a\x00b" },
+ .{ .mime = "text/html", .data = "<b>plain</b>" },
+ .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" },
+ .{ .mime = "image/png", .data = "\x89PNG" },
+ };
+ S.next_result = .busy;
+ const handler = &t.?.stream.handler;
+ const write_result = handler.effects.clipboard_write.?(handler, .{
+ .location = .primary,
+ .contents = &internal_contents,
+ });
+ try testing.expectEqual(clipboard.WriteResult.busy, write_result);
+ try testing.expectEqual(@as(usize, 7), S.count);
+ try testing.expectEqual(@as(usize, 5), S.last_contents_len);
+ try testing.expectEqualStrings(
+ "application/octet-stream",
+ S.last_mimes[1][0..S.last_mime_lens[1]],
+ );
+ try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]);
+ try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
+ try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
+
+ // Removing the callback takes effect immediately.
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ const after_remove = "\x1B]52;c;eA==\x1B\\";
+ vt_write(t, after_remove, after_remove.len);
+ try testing.expectEqual(@as(usize, 7), S.count);
+}
+
+test "clipboard_write without callback is unsupported and silent" {
+ 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);
+
+ // OSC 52 without a callback should not crash
+ const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
+ vt_write(t, seq, seq.len);
+
+ const handler = &t.?.stream.handler;
+ const result = handler.effects.clipboard_write.?(handler, .{
+ .location = .standard,
+ .contents = &.{.{ .mime = "text/plain", .data = "hello" }},
+ });
+ try testing.expectEqual(clipboard.WriteResult.unsupported, result);
+}
+
+test "pwd_changed without callback is silent" {
+ 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);
+
+ // OSC 7 without a callback should not crash, but should still set the pwd
+ const seq = "\x1B]7;file:///tmp\x1B\\";
+ vt_write(t, seq, seq.len);
+ try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
+}
+
test "set size callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -3106,6 +3788,33 @@ test "set color sets dirty flag" {
try testing.expect(zt.flags.dirty.palette);
}
+test "set glyph protocol disables APC handling and clears glossary" {
+ 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 register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\";
+ vt_write(t, register, register.len);
+ try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ const disabled = false;
+ try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled)));
+ try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph));
+ try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ vt_write(t, register, register.len);
+ try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ const enabled = true;
+ try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled)));
+ vt_write(t, register, register.len);
+ try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
+}
+
test "get_multi success" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig
index de043a03..f160f385 100644
--- a/vendor/libghostty-vt/src/terminal/c/types.zig
+++ b/vendor/libghostty-vt/src/terminal/c/types.zig
@@ -7,6 +7,7 @@
const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
+const color_c = @import("color.zig");
const mouse_event = @import("mouse_event.zig");
const point = @import("../point.zig");
const size_report = @import("size_report.zig");
@@ -37,8 +38,12 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
@setEvalBranchQuota(10_000);
break :structs .initComptime(.{
.{ "GhosttyBuffer", StructInfo.init(lib.Buffer) },
+ .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) },
+ .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) },
.{ "GhosttyCodepoints", StructInfo.init(Codepoints) },
+ .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) },
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
+ .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
@@ -209,9 +214,23 @@ test "json parses" {
const root = parsed.value.object;
// Verify we have all expected structs
+ try std.testing.expect(root.contains("GhosttyClipboardContent"));
+ try std.testing.expect(root.contains("GhosttyClipboardWrite"));
try std.testing.expect(root.contains("GhosttyTerminalOptions"));
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
+ const clipboard_content = root.get("GhosttyClipboardContent").?.object;
+ const clipboard_content_fields = clipboard_content.get("fields").?.object;
+ try std.testing.expect(clipboard_content_fields.contains("mime"));
+ try std.testing.expect(clipboard_content_fields.contains("data"));
+
+ const clipboard_write = root.get("GhosttyClipboardWrite").?.object;
+ const clipboard_write_fields = clipboard_write.get("fields").?.object;
+ try std.testing.expect(clipboard_write_fields.contains("size"));
+ try std.testing.expect(clipboard_write_fields.contains("location"));
+ try std.testing.expect(clipboard_write_fields.contains("contents"));
+ try std.testing.expect(clipboard_write_fields.contains("contents_len"));
+
// Verify GhosttyTerminalOptions fields
const term_opts = root.get("GhosttyTerminalOptions").?.object;
try std.testing.expect(term_opts.contains("size"));
diff --git a/vendor/libghostty-vt/src/terminal/c/unicode.zig b/vendor/libghostty-vt/src/terminal/c/unicode.zig
new file mode 100644
index 00000000..14451338
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/unicode.zig
@@ -0,0 +1,85 @@
+const std = @import("std");
+const lib = @import("../lib.zig");
+const unicode_pkg = @import("../../unicode/main.zig");
+
+pub fn codepoint_width(cp: u32) callconv(lib.calling_conv) u8 {
+ if (cp > 0x10FFFF) return 1;
+ return unicode_pkg.codepointWidth(@intCast(cp));
+}
+
+pub fn grapheme_width(
+ cps: ?[*]const u32,
+ len: usize,
+ width: ?*u8,
+) callconv(lib.calling_conv) usize {
+ if (len == 0) {
+ if (width) |ptr| ptr.* = 0;
+ return 0;
+ }
+
+ const ptr = cps orelse {
+ if (width) |out| out.* = 0;
+ return 0;
+ };
+ const result = unicode_pkg.graphemeWidth(u32, ptr[0..len]);
+ if (width) |out| out.* = result.width;
+ return result.len;
+}
+
+test "codepoint_width narrow" {
+ const testing = std.testing;
+ try testing.expectEqual(1, codepoint_width('a'));
+}
+
+test "codepoint_width wide" {
+ const testing = std.testing;
+ try testing.expectEqual(2, codepoint_width(0x4E00));
+}
+
+test "codepoint_width zero" {
+ const testing = std.testing;
+ try testing.expectEqual(0, codepoint_width(0x0301));
+}
+
+test "codepoint_width out of range" {
+ const testing = std.testing;
+ try testing.expectEqual(1, codepoint_width(0x110000));
+ try testing.expectEqual(1, codepoint_width(std.math.maxInt(u32)));
+}
+
+test "grapheme_width empty" {
+ const testing = std.testing;
+
+ var width: u8 = 42;
+ try testing.expectEqual(@as(usize, 0), grapheme_width(null, 0, &width));
+ try testing.expectEqual(@as(u8, 0), width);
+}
+
+test "grapheme_width null width" {
+ const testing = std.testing;
+
+ const cps = [_]u32{ 0x2764, 0xFE0F };
+ try testing.expectEqual(@as(usize, 2), grapheme_width(cps[0..].ptr, cps.len, null));
+}
+
+test "grapheme_width out of range" {
+ const testing = std.testing;
+
+ var width: u8 = 0;
+ const invalid_first = [_]u32{ 0x110000, 0x0301 };
+ try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_first[0..].ptr, invalid_first.len, &width));
+ try testing.expectEqual(@as(u8, 1), width);
+
+ const invalid_second = [_]u32{ 'a', 0x110000 };
+ try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_second[0..].ptr, invalid_second.len, &width));
+ try testing.expectEqual(@as(u8, 1), width);
+}
+
+test "grapheme_width emoji sequence" {
+ const testing = std.testing;
+
+ var width: u8 = 0;
+ const cps = [_]u32{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 };
+ try testing.expectEqual(@as(usize, cps.len), grapheme_width(cps[0..].ptr, cps.len, &width));
+ try testing.expectEqual(@as(u8, 2), width);
+}