diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/c | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig | 575 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/color_scheme.zig | 63 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/grid_ref.zig | 17 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig | 317 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/main.zig | 23 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/render.zig | 173 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/terminal.zig | 711 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/types.zig | 19 | ||||
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/c/unicode.zig | 85 |
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); +} |
