From 3a7e7f905be2feac90f61b5111dca390e726a643 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Wed, 17 Sep 2025 22:03:00 -0700 Subject: [PATCH 01/52] Give the autoformatter what it wants --- src/font/face/freetype.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 82cf107c8..e63b55726 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -170,7 +170,7 @@ pub const Face = struct { if (string.len > 1024) break :skip; var tmp: [512]u16 = undefined; const max = string.len / 2; - for (@as([]const u16, @alignCast(@ptrCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c); + for (@as([]const u16, @ptrCast(@alignCast(string))), 0..) |c, j| tmp[j] = @byteSwap(c); const len = std.unicode.utf16LeToUtf8(buf, tmp[0..max]) catch return string; return buf[0..len]; } From cc165990ecfbc9f962e178e3c82dc1352729f5dc Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Wed, 17 Sep 2025 22:03:53 -0700 Subject: [PATCH 02/52] Use outline bbox for ascii_height measurement --- src/font/face/freetype.zig | 57 ++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index e63b55726..1cd789a66 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -985,14 +985,21 @@ pub const Face = struct { f26dot6ToF64(glyph.*.advance.x), max, ); - top = @max( - f26dot6ToF64(glyph.*.metrics.horiBearingY), - top, - ); - bottom = @min( - f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), - bottom, - ); + // We use the outline's bbox instead of the built-in + // metrics for better accuracy (see renderGlyph()). + const ymin, const ymax = metrics: { + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + break :metrics .{ bbox.yMin, bbox.yMax }; + } + break :metrics .{ + glyph.*.metrics.horiBearingY - glyph.*.metrics.height, + glyph.*.metrics.horiBearingY, + }; + }; + top = @max(f26dot6ToF64(ymax), top); + bottom = @min(f26dot6ToF64(ymin), bottom); } else |_| {} } } @@ -1035,7 +1042,15 @@ pub const Face = struct { .render = false, .no_svg = true, })) { - break :cap f26dot6ToF64(face.handle.*.glyph.*.metrics.height); + const glyph = face.handle.*.glyph; + // We use the outline's bbox instead of the built-in + // metrics for better accuracy (see renderGlyph()). + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + break :cap f26dot6ToF64(bbox.yMax - bbox.yMin); + } + break :cap f26dot6ToF64(glyph.*.metrics.height); } else |_| {} } break :cap null; @@ -1048,7 +1063,15 @@ pub const Face = struct { .render = false, .no_svg = true, })) { - break :ex f26dot6ToF64(face.handle.*.glyph.*.metrics.height); + const glyph = face.handle.*.glyph; + // We use the outline's bbox instead of the built-in + // metrics for better accuracy (see renderGlyph()). + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + break :ex f26dot6ToF64(bbox.yMax - bbox.yMin); + } + break :ex f26dot6ToF64(glyph.*.metrics.height); } else |_| {} } break :ex null; @@ -1078,14 +1101,24 @@ pub const Face = struct { // This can sometimes happen if there's a CJK font that has been // patched with the nerd fonts patcher and it butchers the advance // values so the advance ends up half the width of the actual glyph. - if (ft_glyph.*.metrics.width > ft_glyph.*.advance.x) { + const ft_glyph_width = ft_glyph_width: { + // We use the outline's bbox instead of the built-in + // metrics for better accuracy (see renderGlyph()). + if (ft_glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&ft_glyph.*.outline, &bbox); + break :ft_glyph_width bbox.xMax - bbox.xMin; + } + break :ft_glyph_width ft_glyph.*.metrics.width; + }; + if (ft_glyph_width > ft_glyph.*.advance.x) { var buf: [1024]u8 = undefined; const font_name = self.name(&buf) catch ""; log.warn( "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", .{ font_name, - f26dot6ToF64(ft_glyph.*.metrics.width), + f26dot6ToF64(ft_glyph_width), f26dot6ToF64(ft_glyph.*.advance.x), }, ); From e1b2f6f02182192d6c040dbebc883615d4d5bbbd Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Wed, 17 Sep 2025 22:04:59 -0700 Subject: [PATCH 03/52] Use same hinting flags for measurement and rendering --- pkg/freetype/main.zig | 1 + src/font/face/freetype.zig | 31 +++++++++++++++---------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/freetype/main.zig b/pkg/freetype/main.zig index b39650423..6ec818181 100644 --- a/pkg/freetype/main.zig +++ b/pkg/freetype/main.zig @@ -9,6 +9,7 @@ pub const Library = @import("Library.zig"); pub const Error = errors.Error; pub const Face = face.Face; +pub const LoadFlags = face.LoadFlags; pub const Tag = tag.Tag; pub const mulFix = computations.mulFix; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 1cd789a66..c448d2735 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -956,6 +956,17 @@ pub const Face = struct { break :st .{ pos, thick }; }; + // Set the load flags to use when measuring glyphs. For consistency, we + // use same hinting settings as when rendering for consistency. + const measurement_load_flags: freetype.LoadFlags = .{ + .render = false, + .no_hinting = !self.load_flags.hinting, + .force_autohint = self.load_flags.@"force-autohint", + .no_autohint = !self.load_flags.autohint, + .target_mono = self.load_flags.monochrome, + .no_svg = true, + }; + // Cell width is calculated by calculating the widest width of the // visible ASCII characters. Usually 'M' is widest but we just take // whatever is widest. @@ -976,10 +987,7 @@ pub const Face = struct { var c: u8 = ' '; while (c < 127) : (c += 1) { if (face.getCharIndex(c)) |glyph_index| { - if (face.loadGlyph(glyph_index, .{ - .render = false, - .no_svg = true, - })) { + if (face.loadGlyph(glyph_index, measurement_load_flags)) { const glyph = face.handle.*.glyph; max = @max( f26dot6ToF64(glyph.*.advance.x), @@ -1038,10 +1046,7 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); if (face.getCharIndex('H')) |glyph_index| { - if (face.loadGlyph(glyph_index, .{ - .render = false, - .no_svg = true, - })) { + if (face.loadGlyph(glyph_index, measurement_load_flags)) { const glyph = face.handle.*.glyph; // We use the outline's bbox instead of the built-in // metrics for better accuracy (see renderGlyph()). @@ -1059,10 +1064,7 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); if (face.getCharIndex('x')) |glyph_index| { - if (face.loadGlyph(glyph_index, .{ - .render = false, - .no_svg = true, - })) { + if (face.loadGlyph(glyph_index, measurement_load_flags)) { const glyph = face.handle.*.glyph; // We use the outline's bbox instead of the built-in // metrics for better accuracy (see renderGlyph()). @@ -1086,10 +1088,7 @@ pub const Face = struct { const glyph = face.getCharIndex('水') orelse break :ic_width null; - face.loadGlyph(glyph, .{ - .render = false, - .no_svg = true, - }) catch break :ic_width null; + face.loadGlyph(glyph, measurement_load_flags) catch break :ic_width null; const ft_glyph = face.handle.*.glyph; From 03a707b2c0d31f2a228d5578abf6c9c46291c443 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Wed, 17 Sep 2025 19:05:13 -0700 Subject: [PATCH 04/52] Add tests for font metrics and their estimators --- src/font/Collection.zig | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index ad9590d70..c06358cbf 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -1369,3 +1369,125 @@ test "adjusted sizes" { ); } } + +test "face metrics" { + const testing = std.testing; + const alloc = testing.allocator; + const narrowFont = font.embedded.cozette; + const wideFont = font.embedded.geist_mono; + + var lib = try Library.init(alloc); + defer lib.deinit(); + + var c = init(); + defer c.deinit(alloc); + const size: DesiredSize = .{ .points = 12, .xdpi = 96, .ydpi = 96 }; + c.load_options = .{ .library = lib, .size = size }; + + const narrowIndex = try c.add(alloc, try .init( + lib, + narrowFont, + .{ .size = size }, + ), .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }); + const wideIndex = try c.add(alloc, try .init( + lib, + wideFont, + .{ .size = size }, + ), .{ + .style = .regular, + .fallback = false, + .size_adjustment = .none, + }); + + const narrowMetrics = (try c.getFace(narrowIndex)).getMetrics(); + const wideMetrics = (try c.getFace(wideIndex)).getMetrics(); + + // Verify provided/measured metrics. Measured + // values are backend-dependent due to hinting. + if (options.backend != .web_canvas) { + try std.testing.expectEqual(font.Metrics.FaceMetrics{ + .px_per_em = 16.0, + .cell_width = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 8.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 7.3828125, + .web_canvas => unreachable, + }, + .ascent = 12.3046875, + .descent = -3.6953125, + .line_gap = 0.0, + .underline_position = -1.2265625, + .underline_thickness = 1.2265625, + .strikethrough_position = 6.15625, + .strikethrough_thickness = 1.234375, + .cap_height = 9.84375, + .ex_height = 7.3828125, + .ascii_height = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 18.0625, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 16.0, + .web_canvas => unreachable, + }, + }, narrowMetrics); + try std.testing.expectEqual(font.Metrics.FaceMetrics{ + .px_per_em = 16.0, + .cell_width = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 10.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 9.6, + .web_canvas => unreachable, + }, + .ascent = 14.72, + .descent = -3.52, + .line_gap = 1.6, + .underline_position = -1.6, + .underline_thickness = 0.8, + .strikethrough_position = 4.24, + .strikethrough_thickness = 0.8, + .cap_height = 11.36, + .ex_height = 8.48, + .ascii_height = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 16.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 15.472000000000001, + .web_canvas => unreachable, + }, + }, wideMetrics); + } + + // Verify estimated metrics. icWidth() should equal the smaller of + // 2 * cell_width and ascii_height. For a narrow (wide) font, the + // smaller quantity is the former (latter). + try std.testing.expectEqual( + 2 * narrowMetrics.cell_width, + narrowMetrics.icWidth(), + ); + try std.testing.expectEqual( + wideMetrics.ascii_height, + wideMetrics.icWidth(), + ); +} From bb607e0999f35cf24b31d5c861fd16414130c94f Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 18 Sep 2025 09:08:08 -0700 Subject: [PATCH 05/52] Refactor load flags into a function --- src/font/face/freetype.zig | 57 +++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index c448d2735..e3d4b34cc 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -351,26 +351,16 @@ pub const Face = struct { return glyph.*.bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_BGRA; } - /// Render a glyph using the glyph index. The rendered glyph is stored in the - /// given texture atlas. - pub fn renderGlyph( - self: Face, - alloc: Allocator, - atlas: *font.Atlas, - glyph_index: u32, - opts: font.face.RenderOptions, - ) !Glyph { - self.ft_mutex.lock(); - defer self.ft_mutex.unlock(); - + /// Set the load flags to use when loading a glyph for measurement or + /// rendering. + fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags { // Hinting should only be enabled if the configured load flags specify // it and the provided constraint doesn't actually do anything, since // if it does, then it'll mess up the hinting anyway when it moves or // resizes the glyph. - const do_hinting = self.load_flags.hinting and !opts.constraint.doesAnything(); + const do_hinting = self.load_flags.hinting and !constrained; - // Load the glyph. - try self.face.loadGlyph(glyph_index, .{ + return .{ // If our glyph has color, we want to render the color .color = self.face.hasColor(), @@ -392,7 +382,23 @@ pub const Face = struct { // SVG glyphs under FreeType, since that requires bundling another // dependency to handle rendering the SVG. .no_svg = true, - }); + }; + } + + /// Render a glyph using the glyph index. The rendered glyph is stored in the + /// given texture atlas. + pub fn renderGlyph( + self: Face, + alloc: Allocator, + atlas: *font.Atlas, + glyph_index: u32, + opts: font.face.RenderOptions, + ) !Glyph { + self.ft_mutex.lock(); + defer self.ft_mutex.unlock(); + + // Load the glyph. + try self.face.loadGlyph(glyph_index, self.glyphLoadFlags(opts.constraint.doesAnything())); const glyph = self.face.handle.*.glyph; // We get a rect that represents the position @@ -956,17 +962,6 @@ pub const Face = struct { break :st .{ pos, thick }; }; - // Set the load flags to use when measuring glyphs. For consistency, we - // use same hinting settings as when rendering for consistency. - const measurement_load_flags: freetype.LoadFlags = .{ - .render = false, - .no_hinting = !self.load_flags.hinting, - .force_autohint = self.load_flags.@"force-autohint", - .no_autohint = !self.load_flags.autohint, - .target_mono = self.load_flags.monochrome, - .no_svg = true, - }; - // Cell width is calculated by calculating the widest width of the // visible ASCII characters. Usually 'M' is widest but we just take // whatever is widest. @@ -987,7 +982,7 @@ pub const Face = struct { var c: u8 = ' '; while (c < 127) : (c += 1) { if (face.getCharIndex(c)) |glyph_index| { - if (face.loadGlyph(glyph_index, measurement_load_flags)) { + if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) { const glyph = face.handle.*.glyph; max = @max( f26dot6ToF64(glyph.*.advance.x), @@ -1046,7 +1041,7 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); if (face.getCharIndex('H')) |glyph_index| { - if (face.loadGlyph(glyph_index, measurement_load_flags)) { + if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) { const glyph = face.handle.*.glyph; // We use the outline's bbox instead of the built-in // metrics for better accuracy (see renderGlyph()). @@ -1064,7 +1059,7 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); if (face.getCharIndex('x')) |glyph_index| { - if (face.loadGlyph(glyph_index, measurement_load_flags)) { + if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) { const glyph = face.handle.*.glyph; // We use the outline's bbox instead of the built-in // metrics for better accuracy (see renderGlyph()). @@ -1088,7 +1083,7 @@ pub const Face = struct { const glyph = face.getCharIndex('水') orelse break :ic_width null; - face.loadGlyph(glyph, measurement_load_flags) catch break :ic_width null; + face.loadGlyph(glyph, self.glyphLoadFlags(false)) catch break :ic_width null; const ft_glyph = face.handle.*.glyph; From 4af4e18725b7cdfd3632bcc7eabd5a82c465ea55 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 18 Sep 2025 12:34:32 -0700 Subject: [PATCH 06/52] Use approximate equality for float comparisons --- src/font/Collection.zig | 173 +++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 71 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index c06358cbf..5a66749d6 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -1371,6 +1371,9 @@ test "adjusted sizes" { } test "face metrics" { + // The web canvas backend doesn't calculate face metrics, only cell metrics + if (options.backend != .web_canvas) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; const narrowFont = font.embedded.cozette; @@ -1403,80 +1406,108 @@ test "face metrics" { .size_adjustment = .none, }); - const narrowMetrics = (try c.getFace(narrowIndex)).getMetrics(); - const wideMetrics = (try c.getFace(wideIndex)).getMetrics(); + const narrowMetrics: font.Metrics.FaceMetrics = (try c.getFace(narrowIndex)).getMetrics(); + const wideMetrics: font.Metrics.FaceMetrics = (try c.getFace(wideIndex)).getMetrics(); // Verify provided/measured metrics. Measured // values are backend-dependent due to hinting. - if (options.backend != .web_canvas) { - try std.testing.expectEqual(font.Metrics.FaceMetrics{ - .px_per_em = 16.0, - .cell_width = switch (options.backend) { - .freetype, - .fontconfig_freetype, - .coretext_freetype, - => 8.0, - .coretext, - .coretext_harfbuzz, - .coretext_noshape, - => 7.3828125, - .web_canvas => unreachable, - }, - .ascent = 12.3046875, - .descent = -3.6953125, - .line_gap = 0.0, - .underline_position = -1.2265625, - .underline_thickness = 1.2265625, - .strikethrough_position = 6.15625, - .strikethrough_thickness = 1.234375, - .cap_height = 9.84375, - .ex_height = 7.3828125, - .ascii_height = switch (options.backend) { - .freetype, - .fontconfig_freetype, - .coretext_freetype, - => 18.0625, - .coretext, - .coretext_harfbuzz, - .coretext_noshape, - => 16.0, - .web_canvas => unreachable, - }, - }, narrowMetrics); - try std.testing.expectEqual(font.Metrics.FaceMetrics{ - .px_per_em = 16.0, - .cell_width = switch (options.backend) { - .freetype, - .fontconfig_freetype, - .coretext_freetype, - => 10.0, - .coretext, - .coretext_harfbuzz, - .coretext_noshape, - => 9.6, - .web_canvas => unreachable, - }, - .ascent = 14.72, - .descent = -3.52, - .line_gap = 1.6, - .underline_position = -1.6, - .underline_thickness = 0.8, - .strikethrough_position = 4.24, - .strikethrough_thickness = 0.8, - .cap_height = 11.36, - .ex_height = 8.48, - .ascii_height = switch (options.backend) { - .freetype, - .fontconfig_freetype, - .coretext_freetype, - => 16.0, - .coretext, - .coretext_harfbuzz, - .coretext_noshape, - => 15.472000000000001, - .web_canvas => unreachable, - }, - }, wideMetrics); + const narrowMetricsExpected = font.Metrics.FaceMetrics{ + .px_per_em = 16.0, + .cell_width = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 8.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 7.3828125, + .web_canvas => unreachable, + }, + .ascent = 12.3046875, + .descent = -3.6953125, + .line_gap = 0.0, + .underline_position = -1.2265625, + .underline_thickness = 1.2265625, + .strikethrough_position = 6.15625, + .strikethrough_thickness = 1.234375, + .cap_height = 9.84375, + .ex_height = 7.3828125, + .ascii_height = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 18.0625, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 16.0, + .web_canvas => unreachable, + }, + }; + const wideMetricsExpected = font.Metrics.FaceMetrics{ + .px_per_em = 16.0, + .cell_width = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 10.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 9.6, + .web_canvas => unreachable, + }, + .ascent = 14.72, + .descent = -3.52, + .line_gap = 1.6, + .underline_position = -1.6, + .underline_thickness = 0.8, + .strikethrough_position = 4.24, + .strikethrough_thickness = 0.8, + .cap_height = 11.36, + .ex_height = 8.48, + .ascii_height = switch (options.backend) { + .freetype, + .fontconfig_freetype, + .coretext_freetype, + => 16.0, + .coretext, + .coretext_harfbuzz, + .coretext_noshape, + => 15.472000000000001, + .web_canvas => unreachable, + }, + }; + + inline for ( + .{ narrowMetricsExpected, wideMetricsExpected }, + .{ narrowMetrics, wideMetrics }, + ) |metricsExpected, metricsActual| { + inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| { + const expected = @field(metricsExpected, field.name); + const actual = @field(metricsActual, field.name); + // Unwrap optional fields + const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) { + .optional => |Tinfo| { + if (expected) |expectedValue| { + const actualValue = actual orelse std.math.nan(Tinfo.child); + break :unwrap .{ expectedValue, actualValue }; + } + // Null values can be compared directly + try std.testing.expectEqual(expected, actual); + continue; + }, + else => break :unwrap .{ expected, actual }, + }; + // All non-null values are floats + const eps = std.math.floatEps(@TypeOf(actualValue - expectedValue)); + try std.testing.expectApproxEqRel( + expectedValue, + actualValue, + std.math.sqrt(eps), + ); + } } // Verify estimated metrics. icWidth() should equal the smaller of From 8fe9c579ef945228ccd4f604d23fd6670890cbfb Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 18 Sep 2025 12:39:19 -0700 Subject: [PATCH 07/52] Drop the nan sentinel; just fall through instead --- src/font/Collection.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/font/Collection.zig b/src/font/Collection.zig index 5a66749d6..0ab353a02 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -1489,11 +1489,10 @@ test "face metrics" { const actual = @field(metricsActual, field.name); // Unwrap optional fields const expectedValue, const actualValue = unwrap: switch (@typeInfo(field.type)) { - .optional => |Tinfo| { - if (expected) |expectedValue| { - const actualValue = actual orelse std.math.nan(Tinfo.child); + .optional => { + if (expected) |expectedValue| if (actual) |actualValue| { break :unwrap .{ expectedValue, actualValue }; - } + }; // Null values can be compared directly try std.testing.expectEqual(expected, actual); continue; From 333a32208e2988661f76cd04d6680ffcd4e0f575 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 18 Sep 2025 14:01:00 -0700 Subject: [PATCH 08/52] Factor out glyph rect function --- src/font/face/freetype.zig | 120 ++++++++++++------------------------- 1 file changed, 39 insertions(+), 81 deletions(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index e3d4b34cc..0dc4c4c03 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -385,6 +385,34 @@ pub const Face = struct { }; } + /// Get a rect that represents the position and size of the loaded glyph. + fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.RenderOptions.Constraint.GlyphSize { + // If we're dealing with an outline glyph then we get the + // outline's bounding box instead of using the built-in + // metrics, since that's more precise and allows better + // cell-fitting. + if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { + // Get the glyph's bounding box before we transform it at all. + // We use this rather than the metrics, since it's more precise. + var bbox: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); + + return .{ + .x = f26dot6ToF64(bbox.xMin), + .y = f26dot6ToF64(bbox.yMin), + .width = f26dot6ToF64(bbox.xMax - bbox.xMin), + .height = f26dot6ToF64(bbox.yMax - bbox.yMin), + }; + } + + return .{ + .x = f26dot6ToF64(glyph.*.metrics.horiBearingX), + .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), + .width = f26dot6ToF64(glyph.*.metrics.width), + .height = f26dot6ToF64(glyph.*.metrics.height), + }; + } + /// Render a glyph using the glyph index. The rendered glyph is stored in the /// given texture atlas. pub fn renderGlyph( @@ -403,37 +431,7 @@ pub const Face = struct { // We get a rect that represents the position // and size of the glyph before any changes. - const rect: struct { - x: f64, - y: f64, - width: f64, - height: f64, - } = metrics: { - // If we're dealing with an outline glyph then we get the - // outline's bounding box instead of using the built-in - // metrics, since that's more precise and allows better - // cell-fitting. - if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { - // Get the glyph's bounding box before we transform it at all. - // We use this rather than the metrics, since it's more precise. - var bbox: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); - - break :metrics .{ - .x = f26dot6ToF64(bbox.xMin), - .y = f26dot6ToF64(bbox.yMin), - .width = f26dot6ToF64(bbox.xMax - bbox.xMin), - .height = f26dot6ToF64(bbox.yMax - bbox.yMin), - }; - } - - break :metrics .{ - .x = f26dot6ToF64(glyph.*.metrics.horiBearingX), - .y = f26dot6ToF64(glyph.*.metrics.horiBearingY - glyph.*.metrics.height), - .width = f26dot6ToF64(glyph.*.metrics.width), - .height = f26dot6ToF64(glyph.*.metrics.height), - }; - }; + const rect = getGlyphSize(glyph); // If our glyph is smaller than a quarter pixel in either axis // then it has no outlines or they're too small to render. @@ -988,21 +986,9 @@ pub const Face = struct { f26dot6ToF64(glyph.*.advance.x), max, ); - // We use the outline's bbox instead of the built-in - // metrics for better accuracy (see renderGlyph()). - const ymin, const ymax = metrics: { - if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { - var bbox: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); - break :metrics .{ bbox.yMin, bbox.yMax }; - } - break :metrics .{ - glyph.*.metrics.horiBearingY - glyph.*.metrics.height, - glyph.*.metrics.horiBearingY, - }; - }; - top = @max(f26dot6ToF64(ymax), top); - bottom = @min(f26dot6ToF64(ymin), bottom); + const rect = getGlyphSize(glyph); + top = @max(rect.y + rect.height, top); + bottom = @min(rect.y, bottom); } else |_| {} } } @@ -1042,15 +1028,7 @@ pub const Face = struct { defer self.ft_mutex.unlock(); if (face.getCharIndex('H')) |glyph_index| { if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) { - const glyph = face.handle.*.glyph; - // We use the outline's bbox instead of the built-in - // metrics for better accuracy (see renderGlyph()). - if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { - var bbox: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); - break :cap f26dot6ToF64(bbox.yMax - bbox.yMin); - } - break :cap f26dot6ToF64(glyph.*.metrics.height); + break :cap getGlyphSize(face.handle.*.glyph).height; } else |_| {} } break :cap null; @@ -1060,15 +1038,7 @@ pub const Face = struct { defer self.ft_mutex.unlock(); if (face.getCharIndex('x')) |glyph_index| { if (face.loadGlyph(glyph_index, self.glyphLoadFlags(false))) { - const glyph = face.handle.*.glyph; - // We use the outline's bbox instead of the built-in - // metrics for better accuracy (see renderGlyph()). - if (glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { - var bbox: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox); - break :ex f26dot6ToF64(bbox.yMax - bbox.yMin); - } - break :ex f26dot6ToF64(glyph.*.metrics.height); + break :ex getGlyphSize(face.handle.*.glyph).height; } else |_| {} } break :ex null; @@ -1095,31 +1065,19 @@ pub const Face = struct { // This can sometimes happen if there's a CJK font that has been // patched with the nerd fonts patcher and it butchers the advance // values so the advance ends up half the width of the actual glyph. - const ft_glyph_width = ft_glyph_width: { - // We use the outline's bbox instead of the built-in - // metrics for better accuracy (see renderGlyph()). - if (ft_glyph.*.format == freetype.c.FT_GLYPH_FORMAT_OUTLINE) { - var bbox: freetype.c.FT_BBox = undefined; - _ = freetype.c.FT_Outline_Get_BBox(&ft_glyph.*.outline, &bbox); - break :ft_glyph_width bbox.xMax - bbox.xMin; - } - break :ft_glyph_width ft_glyph.*.metrics.width; - }; - if (ft_glyph_width > ft_glyph.*.advance.x) { + const ft_glyph_width = getGlyphSize(ft_glyph).width; + const advance = f26dot6ToF64(ft_glyph.*.advance.x); + if (ft_glyph_width > advance) { var buf: [1024]u8 = undefined; const font_name = self.name(&buf) catch ""; log.warn( "(getMetrics) Width of glyph '水' for font \"{s}\" is greater than its advance ({d} > {d}), discarding ic_width metric.", - .{ - font_name, - f26dot6ToF64(ft_glyph_width), - f26dot6ToF64(ft_glyph.*.advance.x), - }, + .{ font_name, ft_glyph_width, advance }, ); break :ic_width null; } - break :ic_width f26dot6ToF64(ft_glyph.*.advance.x); + break :ic_width advance; }; return .{ From 52ef17d4e0012e79b0f1db1d4119fbb17ce8d9bd Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 21 Sep 2025 11:04:32 -0700 Subject: [PATCH 09/52] Hoist `GlyphSize` out of nested scopes --- src/font/face.zig | 16 ++++++++-------- src/font/face/freetype.zig | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/font/face.zig b/src/font/face.zig index 9da3c30f6..5eb84c898 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -93,6 +93,14 @@ pub const Variation = struct { }; }; +/// The size and position of a glyph. +pub const GlyphSize = struct { + width: f64, + height: f64, + x: f64, + y: f64, +}; + /// Additional options for rendering glyphs. pub const RenderOptions = struct { /// The metrics that are defining the grid layout. These are usually @@ -214,14 +222,6 @@ pub const RenderOptions = struct { icon, }; - /// The size and position of a glyph. - pub const GlyphSize = struct { - width: f64, - height: f64, - x: f64, - y: f64, - }; - /// Returns true if the constraint does anything. If it doesn't, /// because it neither sizes nor positions the glyph, then this /// returns false. diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 0dc4c4c03..0ccc84c44 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -386,7 +386,7 @@ pub const Face = struct { } /// Get a rect that represents the position and size of the loaded glyph. - fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.RenderOptions.Constraint.GlyphSize { + fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize { // If we're dealing with an outline glyph then we get the // outline's bounding box instead of using the built-in // metrics, since that's more precise and allows better From 13d44129bf39ff383a69b73fcf89ffeacc03a40e Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 21 Sep 2025 16:30:06 -0700 Subject: [PATCH 10/52] Add constraint width tests --- src/terminal/Screen.zig | 95 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 67769923f..ab5aac3d4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -14,6 +14,7 @@ const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); const pagepkg = @import("page.zig"); +const cellpkg = @import("../renderer/cell.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const style = @import("style.zig"); @@ -9094,3 +9095,97 @@ test "Screen UTF8 cell map with blank prefix" { .y = 1, }, cell_map.items[3]); } + +test "Screen cell constraint widths" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, 4, 1, 0); + defer s.deinit(); + + // for each case, the numbers in the comment denote expected + // constraint widths for the symbol-containing cells + + // symbol->nothing: 2 + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(2, cellpkg.constraintWidth(p0)); + s.reset(); + } + + // symbol->character: 1 + { + try s.testWriteString("z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, cellpkg.constraintWidth(p0)); + s.reset(); + } + + // symbol->space: 2 + { + try s.testWriteString(" z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(2, cellpkg.constraintWidth(p0)); + s.reset(); + } + // symbol->no-break space: 1 + { + try s.testWriteString("\u{00a0}z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, cellpkg.constraintWidth(p0)); + s.reset(); + } + + // symbol->end of row: 1 + { + try s.testWriteString(" "); + const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; + try testing.expectEqual(1, cellpkg.constraintWidth(p3)); + s.reset(); + } + + // character->symbol: 2 + { + try s.testWriteString("z"); + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(2, cellpkg.constraintWidth(p1)); + s.reset(); + } + + // symbol->symbol: 1,1 + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(1, cellpkg.constraintWidth(p0)); + try testing.expectEqual(1, cellpkg.constraintWidth(p1)); + s.reset(); + } + + // symbol->space->symbol: 2,2 + { + try s.testWriteString(" "); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; + try testing.expectEqual(2, cellpkg.constraintWidth(p0)); + try testing.expectEqual(2, cellpkg.constraintWidth(p2)); + s.reset(); + } + + // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, cellpkg.constraintWidth(p0)); + s.reset(); + } + + // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) + { + try s.testWriteString(""); + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(2, cellpkg.constraintWidth(p1)); + s.reset(); + } +} From 2f19d6bb7355c9957ae373ec9cdf999f7fda0c2a Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Thu, 18 Sep 2025 00:03:27 -0700 Subject: [PATCH 11/52] Treat Powerline glyphs like normal characters ...not whitespace. Powerline glyphs can be considered an extension of the Block Elements unicode block, which is neither whitespace nor symbols (icons). This ensures that characters immediately followed by a powerline glyph are constrained to a single cell (unlike the current behavior where a PL glyph is considered whitespace), while symbols (icons) immediately preceded by a powerline glyph are not (unlike if a PL glyph were considered a symbol). This resolves https://discord.com/channels/1005603569187160125/1417236683266592798 --- src/renderer/cell.zig | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 3cf306f91..206bb9d81 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -236,8 +236,8 @@ pub fn isCovering(cp: u21) bool { } /// Returns true of the codepoint is a "symbol-like" character, which -/// for now we define as anything in a private use area and anything -/// in several unicode blocks: +/// for now we define as anything in a private use area, except +/// the Powerline range, and anything in several unicode blocks: /// - Dingbats /// - Emoticons /// - Miscellaneous Symbols @@ -249,11 +249,13 @@ pub fn isCovering(cp: u21) bool { /// In the future it may be prudent to expand this to encompass more /// symbol-like characters, and/or exclude some PUA sections. pub fn isSymbol(cp: u21) bool { - return symbols.get(cp); + return symbols.get(cp) and !isPowerline(cp); } /// Returns the appropriate `constraint_width` for /// the provided cell when rendering its glyph(s). +/// +/// Tested as part of the Screen tests. pub fn constraintWidth(cell_pin: terminal.Pin) u2 { const cell = cell_pin.rowAndCell().cell; const cp = cell.codepoint(); @@ -274,9 +276,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { // If we have a previous cell and it was a symbol then we need // to also constrain. This is so that multiple PUA glyphs align. - // As an exception, we ignore powerline glyphs since they are - // used for box drawing and we consider them whitespace. - if (cell_pin.x > 0) prev: { + if (cell_pin.x > 0) { const prev_cp = prev_cp: { var copy = cell_pin; copy.x -= 1; @@ -284,9 +284,6 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { break :prev_cp prev_cell.codepoint(); }; - // We consider powerline glyphs whitespace. - if (isPowerline(prev_cp)) break :prev; - if (isSymbol(prev_cp)) { return 1; } @@ -300,10 +297,7 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { const next_cell = copy.rowAndCell().cell; break :next_cp next_cell.codepoint(); }; - if (next_cp == 0 or - isSpace(next_cp) or - isPowerline(next_cp)) - { + if (next_cp == 0 or isSpace(next_cp)) { return 2; } From d1db596039c445844c3a8966c5155c143ba1a0c4 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 21 Sep 2025 13:36:38 -0700 Subject: [PATCH 12/52] Add box drawing characters to the min contrast exclusion --- src/renderer/cell.zig | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 206bb9d81..c55733516 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -306,9 +306,10 @@ pub fn constraintWidth(cell_pin: terminal.Pin) u2 { } /// Whether min contrast should be disabled for a given glyph. +/// True for glyphs used for terminal graphics, such as box +/// drawing characters, block elements, and Powerline glyphs. pub fn noMinContrast(cp: u21) bool { - // TODO: We should disable for all box drawing type characters. - return isPowerline(cp); + return isBoxDrawing(cp) or isBlockElement(cp) or isLegacyComputing(cp) or isPowerline(cp); } // Some general spaces, others intentionally kept @@ -322,6 +323,32 @@ fn isSpace(char: u21) bool { }; } +// Returns true if the codepoint is a box drawing character. +fn isBoxDrawing(char: u21) bool { + return switch (char) { + 0x2500...0x257F => true, + else => false, + }; +} + +// Returns true if the codepoint is a block element. +fn isBlockElement(char: u21) bool { + return switch (char) { + 0x2580...0x259F => true, + else => false, + }; +} + +// Returns true if the codepoint is in a Symbols for Legacy +// Computing block, including supplements. +fn isLegacyComputing(char: u21) bool { + return switch (char) { + 0x1FB00...0x1FBFF => true, + 0x1CC00...0x1CEBF => true, // Supplement introduced in Unicode 16.0 + else => false, + }; +} + // Returns true if the codepoint is a part of the Powerline range. fn isPowerline(char: u21) bool { return switch (char) { From f2fcbd6e5e8224051f6436eb8fd6e0b9bca44416 Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sun, 21 Sep 2025 19:00:35 -0700 Subject: [PATCH 13/52] Add missing codepoints to isPowerline predicate e0d6 and e0d7 were left out. Also collapsed everything to a single range; unlikely that the unused gaps (e0c9, e0cb, e0d3, e0d5) would be used for something else in any font that ships Powerline glyphs. --- src/renderer/cell.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index c55733516..d54e98811 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -352,7 +352,7 @@ fn isLegacyComputing(char: u21) bool { // Returns true if the codepoint is a part of the Powerline range. fn isPowerline(char: u21) bool { return switch (char) { - 0xE0B0...0xE0C8, 0xE0CA, 0xE0CC...0xE0D2, 0xE0D4 => true, + 0xE0B0...0xE0D7 => true, else => false, }; } From 86009545260c2d3609d52a40e7ed44e716ba11a1 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:52:10 +0200 Subject: [PATCH 14/52] Workaround for #8669 --- macos/Sources/Features/App Intents/NewTerminalIntent.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index f7242ee56..6e679673f 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -43,11 +43,13 @@ struct NewTerminalIntent: AppIntent { ) var parent: TerminalEntity? + // Performing in the background can avoid opening multiple windows at the same time + // using `foreground` will cause `perform` and `AppDelegate.applicationDidBecomeActive(_:)`/`AppDelegate.applicationShouldHandleReopen(_:hasVisibleWindows:)` running at the 'same' time @available(macOS 26.0, *) - static var supportedModes: IntentModes = .foreground(.immediate) + static var supportedModes: IntentModes = .background @available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes") - static var openAppWhenRun = true + static var openAppWhenRun = false @MainActor func perform() async throws -> some IntentResult & ReturnsValue { From 8beeebc21de998baa858d353f8261d6d2044f323 Mon Sep 17 00:00:00 2001 From: Lars <134181853+bo2themax@users.noreply.github.com> Date: Mon, 22 Sep 2025 15:27:27 +0200 Subject: [PATCH 15/52] Force Ghostty to be active if not --- macos/Sources/Features/App Intents/NewTerminalIntent.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/Features/App Intents/NewTerminalIntent.swift b/macos/Sources/Features/App Intents/NewTerminalIntent.swift index 6e679673f..46a752198 100644 --- a/macos/Sources/Features/App Intents/NewTerminalIntent.swift +++ b/macos/Sources/Features/App Intents/NewTerminalIntent.swift @@ -98,6 +98,11 @@ struct NewTerminalIntent: AppIntent { parent = nil } + defer { + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + } switch location { case .window: let newController = TerminalController.newWindow( From a96cb9ab574e099ea4f792582facdddfb89e959c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= Date: Tue, 23 Sep 2025 20:42:57 +0200 Subject: [PATCH 16/52] chore: pin zig 0.14.1 in .zigversion --- .zigversion | 1 + 1 file changed, 1 insertion(+) create mode 100644 .zigversion diff --git a/.zigversion b/.zigversion new file mode 100644 index 000000000..930e3000b --- /dev/null +++ b/.zigversion @@ -0,0 +1 @@ +0.14.1 From b0e85d900ec0d383d548bf3962e65ba1851c5260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= Date: Tue, 23 Sep 2025 21:14:09 +0200 Subject: [PATCH 17/52] use build.zig.zon instead --- .zigversion | 1 - build.zig.zon | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .zigversion diff --git a/.zigversion b/.zigversion deleted file mode 100644 index 930e3000b..000000000 --- a/.zigversion +++ /dev/null @@ -1 +0,0 @@ -0.14.1 diff --git a/build.zig.zon b/build.zig.zon index b297f3bb0..91f2c5772 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,6 +3,7 @@ .version = "1.2.1", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, + .minimum_zig_version = "0.14.0", .dependencies = .{ // Zig libs From c5786c5d38f1f7edd986ed4a4d5339e450511df3 Mon Sep 17 00:00:00 2001 From: CoderJoshDK <74162303+CoderJoshDK@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:28:32 -0400 Subject: [PATCH 18/52] fix: alloc free off by one --- include/ghostty.h | 1 + src/config/CApi.zig | 3 ++- src/main_c.zig | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 7888b380c..a2964c227 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -353,6 +353,7 @@ typedef struct { typedef struct { const char* ptr; uintptr_t len; + uintptr_t cap; } ghostty_string_s; typedef struct { diff --git a/src/config/CApi.zig b/src/config/CApi.zig index bdc59797a..f90b0ca24 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -130,7 +130,8 @@ export fn ghostty_config_open_path() c.String { return .empty; }; - return .fromSlice(path); + // Capacity is len + 1 due to sentinel + return .fromSlice(path, path.len + 1); } /// Sync with ghostty_diagnostic_s diff --git a/src/main_c.zig b/src/main_c.zig index 9a9bcc6d2..1212e0b07 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -63,16 +63,19 @@ const Info = extern struct { pub const String = extern struct { ptr: ?[*]const u8, len: usize, + cap: usize, pub const empty: String = .{ .ptr = null, .len = 0, + .cap = 0, }; - pub fn fromSlice(slice: []const u8) String { + pub fn fromSlice(slice: []const u8, cap: usize) String { return .{ .ptr = slice.ptr, .len = slice.len, + .cap = cap, }; } }; @@ -129,5 +132,5 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { /// Free a string allocated by Ghostty. pub export fn ghostty_string_free(str: String) void { - state.alloc.free(str.ptr.?[0..str.len]); + state.alloc.free(str.ptr.?[0..str.cap]); } From 79685f87c420ca7a9d73fee5865bb5046b7df74b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 24 Sep 2025 16:33:18 -0500 Subject: [PATCH 19/52] use comptime to make C String interface nicer --- src/config/CApi.zig | 2 +- src/main_c.zig | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/config/CApi.zig b/src/config/CApi.zig index f90b0ca24..154cc0c9c 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -131,7 +131,7 @@ export fn ghostty_config_open_path() c.String { }; // Capacity is len + 1 due to sentinel - return .fromSlice(path, path.len + 1); + return .fromSlice(path); } /// Sync with ghostty_diagnostic_s diff --git a/src/main_c.zig b/src/main_c.zig index 1212e0b07..a72d82a3e 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -63,21 +63,42 @@ const Info = extern struct { pub const String = extern struct { ptr: ?[*]const u8, len: usize, - cap: usize, + sentinel: bool, pub const empty: String = .{ .ptr = null, .len = 0, - .cap = 0, + .sentinel = false, }; - pub fn fromSlice(slice: []const u8, cap: usize) String { + pub fn fromSlice(slice: anytype) String { return .{ .ptr = slice.ptr, .len = slice.len, - .cap = cap, + .sentinel = sentinel: { + const info = @typeInfo(@TypeOf(slice)); + switch (info) { + .pointer => |p| { + if (p.size != .slice) @compileError("only slices supported"); + if (p.child != u8) @compileError("only u8 slices supported"); + const sentinel_ = p.sentinel(); + if (sentinel_) |sentinel| if (sentinel != 0) @compileError("only 0 is supported for sentinels"); + break :sentinel sentinel_ != null; + }, + else => @compileError("only []const u8 and [:0]const u8"), + } + }, }; } + + pub fn deinit(self: *const String) void { + const ptr = self.ptr orelse return; + if (self.sentinel) { + state.alloc.free(ptr[0..self.len :0]); + } else { + state.alloc.free(ptr[0..self.len]); + } + } }; /// Initialize ghostty global state. @@ -132,5 +153,5 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { /// Free a string allocated by Ghostty. pub export fn ghostty_string_free(str: String) void { - state.alloc.free(str.ptr.?[0..str.cap]); + str.deinit(); } From dc03a47558572dc66ae03350739cda70e1ea4cc5 Mon Sep 17 00:00:00 2001 From: CoderJoshDK <74162303+CoderJoshDK@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:47:24 -0400 Subject: [PATCH 20/52] chore: sync changes with ghostty_string_s --- include/ghostty.h | 2 +- src/config/CApi.zig | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index a2964c227..3f1e0c9d9 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -353,7 +353,7 @@ typedef struct { typedef struct { const char* ptr; uintptr_t len; - uintptr_t cap; + bool sentinel; } ghostty_string_s; typedef struct { diff --git a/src/config/CApi.zig b/src/config/CApi.zig index 154cc0c9c..bdc59797a 100644 --- a/src/config/CApi.zig +++ b/src/config/CApi.zig @@ -130,7 +130,6 @@ export fn ghostty_config_open_path() c.String { return .empty; }; - // Capacity is len + 1 due to sentinel return .fromSlice(path); } From 22cf46aefcbab3c95da1c6ccc58008f2f94e5b91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gautier=20Ben=20A=C3=AFm?= <48261497+GauBen@users.noreply.github.com> Date: Thu, 25 Sep 2025 09:08:57 +0200 Subject: [PATCH 21/52] use 0.14.1 --- build.zig.zon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.zig.zon b/build.zig.zon index 91f2c5772..028f1a0d6 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,7 @@ .version = "1.2.1", .paths = .{""}, .fingerprint = 0x64407a2a0b4147e5, - .minimum_zig_version = "0.14.0", + .minimum_zig_version = "0.14.1", .dependencies = .{ // Zig libs From d79441edd1ec28132f10e867040cd5f646196238 Mon Sep 17 00:00:00 2001 From: CoderJoshDK <74162303+CoderJoshDK@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:53:02 -0400 Subject: [PATCH 22/52] test: valid string slices for ghostty_string_s --- src/main_c.zig | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main_c.zig b/src/main_c.zig index a72d82a3e..d3fb753ef 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -155,3 +155,43 @@ pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 { pub export fn ghostty_string_free(str: String) void { str.deinit(); } + +test "ghostty_string_s empty string" { + const testing = std.testing; + const empty_string = String.empty; + defer empty_string.deinit(); + + try testing.expect(empty_string.len == 0); + try testing.expect(empty_string.sentinel == false); +} + +test "ghostty_string_s c string" { + const testing = std.testing; + state.alloc = testing.allocator; + + const slice: [:0]const u8 = "hello"; + const allocated_slice = try testing.allocator.dupeZ(u8, slice); + const c_null_string = String.fromSlice(allocated_slice); + defer c_null_string.deinit(); + + try testing.expect(allocated_slice[5] == 0); + try testing.expect(@TypeOf(slice) == [:0]const u8); + try testing.expect(@TypeOf(allocated_slice) == [:0]u8); + try testing.expect(c_null_string.len == 5); + try testing.expect(c_null_string.sentinel == true); +} + +test "ghostty_string_s zig string" { + const testing = std.testing; + state.alloc = testing.allocator; + + const slice: []const u8 = "hello"; + const allocated_slice = try testing.allocator.dupe(u8, slice); + const zig_string = String.fromSlice(allocated_slice); + defer zig_string.deinit(); + + try testing.expect(@TypeOf(slice) == []const u8); + try testing.expect(@TypeOf(allocated_slice) == []u8); + try testing.expect(zig_string.len == 5); + try testing.expect(zig_string.sentinel == false); +} From 79a5902ef23a30782df7bb1a2fac6dc20375697b Mon Sep 17 00:00:00 2001 From: Jon Parise Date: Thu, 25 Sep 2025 16:39:11 -0400 Subject: [PATCH 23/52] vim: use :setf to set the filetype This is nicer because it only sets the filetype if it hasn't already been set. :setf[iletype] has been available since vim version 6. See: https://vimhelp.org/options.txt.html#%3Asetf --- src/extra/vim.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extra/vim.zig b/src/extra/vim.zig index e5261cd74..4443fd168 100644 --- a/src/extra/vim.zig +++ b/src/extra/vim.zig @@ -10,7 +10,7 @@ pub const ftdetect = \\" \\" THIS FILE IS AUTO-GENERATED \\ - \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* set ft=ghostty + \\au BufRead,BufNewFile */ghostty/config,*/ghostty/themes/* setf ghostty \\ ; pub const ftplugin = From fdbf0c624204174a49afb6276e88ee70b1ae3182 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 00:06:22 +0000 Subject: [PATCH 24/52] build(deps): bump namespacelabs/nscloud-cache-action Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.2.17 to 1.2.18. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/a289cf5d2fcd6874376aa92f0ef7f99dc923592a...7baedde84bbf5063413d621f282834bc2654d0c1) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-version: 1.2.18 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-tag.yml | 2 +- .github/workflows/release-tip.yml | 2 +- .github/workflows/snap.yml | 2 +- .github/workflows/test.yml | 48 +++++++++++------------ .github/workflows/update-colorschemes.yml | 2 +- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 09ec4aeed..ef6f96555 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,7 +36,7 @@ jobs: - name: Checkout code uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 66dfe5fc2..af912215c 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 853378d43..7f7b85e2f 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -163,7 +163,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 6feb39887..4e9aa168c 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9efd257ca..1638b0fd9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -73,7 +73,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -107,7 +107,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -140,7 +140,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -174,7 +174,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -216,7 +216,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -252,7 +252,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -281,7 +281,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -314,7 +314,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -360,7 +360,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -572,7 +572,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -614,7 +614,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -662,7 +662,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -697,7 +697,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -761,7 +761,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -790,7 +790,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -818,7 +818,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -845,7 +845,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -872,7 +872,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -899,7 +899,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -926,7 +926,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -960,7 +960,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -987,7 +987,7 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -1022,7 +1022,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix @@ -1110,7 +1110,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 3f0d1d1e2..4e9db4225 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@a289cf5d2fcd6874376aa92f0ef7f99dc923592a # v1.2.17 + uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18 with: path: | /nix From 311f8ec70b675d553eccbb7a2d2042b374fab93e Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 26 Sep 2025 11:10:58 -0500 Subject: [PATCH 25/52] build: limit cpu affinity to 32 cpus on Linux Related to #8924 Zig currenly has a bug where it crashes when compiling Ghostty on systems with more than 32 cpus (See the linked issue for the gory details). As a temporary hack, use `sched_setaffinity` on Linux systems to limit the compile to the first 32 cores. Note that this affects the build only. The resulting Ghostty executable is not limited in any way. This is a more general fix than wrapping the Zig compiler with `taskset`. First of all, it requires no action from the user or packagers. Second, it will be easier for us to remove once the upstream Zig bug is fixed. --- build.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/build.zig b/build.zig index c6c461b4c..008fc849e 100644 --- a/build.zig +++ b/build.zig @@ -8,6 +8,10 @@ comptime { } pub fn build(b: *std.Build) !void { + // Works around a Zig but still present in 0.15.1. Remove when fixed. + // https://github.com/ghostty-org/ghostty/issues/8924 + try limitCoresForZigBug(); + // This defines all the available build options (e.g. `-D`). If you // want to know what options are available, you can run `--help` or // you can read `src/build/Config.zig`. @@ -298,3 +302,13 @@ pub fn build(b: *std.Build) !void { try translations_step.addError("cannot update translations when i18n is disabled", .{}); } } + +// WARNING: Remove this when https://github.com/ghostty-org/ghostty/issues/8924 is resolved! +// Limit ourselves to 32 cpus on Linux because of an upstream Zig bug. +fn limitCoresForZigBug() !void { + if (comptime builtin.os.tag != .linux) return; + const pid = std.os.linux.getpid(); + var set: std.bit_set.ArrayBitSet(usize, std.os.linux.CPU_SETSIZE * 8) = .initEmpty(); + for (0..32) |cpu| set.set(cpu); + try std.os.linux.sched_setaffinity(pid, &set.masks); +} From 8a1dc5bd9763fb537606a690fadc5103a1384df8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 13:20:50 -0700 Subject: [PATCH 26/52] terminal: shuffle some C APIs to make it more long term maintainable --- src/terminal/c/main.zig | 12 +++++++++++ src/terminal/{c_api.zig => c/osc.zig} | 29 +++++++++------------------ src/terminal/c/result.zig | 5 +++++ src/terminal/main.zig | 2 +- 4 files changed, 28 insertions(+), 20 deletions(-) create mode 100644 src/terminal/c/main.zig rename src/terminal/{c_api.zig => c/osc.zig} (61%) create mode 100644 src/terminal/c/result.zig diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig new file mode 100644 index 000000000..fa1fd0c6a --- /dev/null +++ b/src/terminal/c/main.zig @@ -0,0 +1,12 @@ +pub const osc = @import("osc.zig"); + +// The full C API, unexported. +pub const osc_new = osc.new; +pub const osc_free = osc.free; + +test { + _ = osc; + + // We want to make sure we run the tests for the C allocator interface. + _ = @import("../../lib/allocator.zig"); +} diff --git a/src/terminal/c_api.zig b/src/terminal/c/osc.zig similarity index 61% rename from src/terminal/c_api.zig rename to src/terminal/c/osc.zig index 194a91d6d..e0024bc17 100644 --- a/src/terminal/c_api.zig +++ b/src/terminal/c/osc.zig @@ -1,22 +1,17 @@ const std = @import("std"); const assert = std.debug.assert; const builtin = @import("builtin"); -const lib_alloc = @import("../lib/allocator.zig"); +const lib_alloc = @import("../../lib/allocator.zig"); const CAllocator = lib_alloc.Allocator; -const osc = @import("osc.zig"); +const osc = @import("../osc.zig"); +const Result = @import("result.zig").Result; /// C: GhosttyOscParser -pub const OscParser = ?*osc.Parser; +pub const Parser = ?*osc.Parser; -/// C: GhosttyResult -pub const Result = enum(c_int) { - success = 0, - out_of_memory = -1, -}; - -pub fn osc_new( +pub fn new( alloc_: ?*const CAllocator, - result: *OscParser, + result: *Parser, ) callconv(.c) Result { const alloc = lib_alloc.default(alloc_); const ptr = alloc.create(osc.Parser) catch @@ -26,7 +21,7 @@ pub fn osc_new( return .success; } -pub fn osc_free(parser_: OscParser) callconv(.c) void { +pub fn free(parser_: Parser) callconv(.c) void { // C-built parsers always have an associated allocator. const parser = parser_ orelse return; const alloc = parser.alloc.?; @@ -34,16 +29,12 @@ pub fn osc_free(parser_: OscParser) callconv(.c) void { alloc.destroy(parser); } -test { - _ = lib_alloc; -} - test "osc" { const testing = std.testing; - var p: OscParser = undefined; - try testing.expectEqual(Result.success, osc_new( + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( &lib_alloc.test_allocator, &p, )); - osc_free(p); + free(p); } diff --git a/src/terminal/c/result.zig b/src/terminal/c/result.zig new file mode 100644 index 000000000..a2ebc9b69 --- /dev/null +++ b/src/terminal/c/result.zig @@ -0,0 +1,5 @@ +/// C: GhosttyResult +pub const Result = enum(c_int) { + success = 0, + out_of_memory = -1, +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 4064c0c9c..832fe6a29 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -64,7 +64,7 @@ pub const isSafePaste = sanitize.isSafePaste; /// This is set to true when we're building the C library. pub const is_c_lib = @import("root") == @import("../lib_vt.zig"); -pub const c_api = @import("c_api.zig"); +pub const c_api = @import("c/main.zig"); test { @import("std").testing.refAllDecls(@This()); From b3d1802c89ce10d73c28821d2d071edbd6ec3df4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 13:22:56 -0700 Subject: [PATCH 27/52] lib_vt: osc_next/reset --- include/ghostty/vt.h | 26 ++++++++++++++++++++++++++ src/lib_vt.zig | 2 ++ src/terminal/c/main.zig | 2 ++ src/terminal/c/osc.zig | 8 ++++++++ src/terminal/osc.zig | 2 +- 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 12ed2d015..657a9e60f 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -214,6 +214,32 @@ GhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParse */ void ghostty_osc_free(GhosttyOscParser parser); +/** + * Reset an OSC parser instance to its initial state. + * + * Resets the parser state, clearing any partially parsed OSC sequences + * and returning the parser to its initial state. This is useful for + * reusing a parser instance or recovering from parse errors. + * + * @param parser The parser handle to reset, must not be null. + */ +void ghostty_osc_reset(GhosttyOscParser parser); + +/** + * Parse the next byte in an OSC sequence. + * + * Processes a single byte as part of an OSC sequence. The parser maintains + * internal state to track the progress through the sequence. Call this + * function for each byte in the sequence data. + * + * When finished pumping the parser with bytes, call ghostty_osc_end + * to get the final result. + * + * @param parser The parser handle, must not be null. + * @param byte The next byte to parse + */ +void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); + #ifdef __cplusplus } #endif diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 656509cce..63a84ad63 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -72,6 +72,8 @@ comptime { const c = terminal.c_api; @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); + @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); + @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index fa1fd0c6a..1a25685ba 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -3,6 +3,8 @@ pub const osc = @import("osc.zig"); // The full C API, unexported. pub const osc_new = osc.new; pub const osc_free = osc.free; +pub const osc_reset = osc.reset; +pub const osc_next = osc.next; test { _ = osc; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index e0024bc17..59761cfba 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -29,6 +29,14 @@ pub fn free(parser_: Parser) callconv(.c) void { alloc.destroy(parser); } +pub fn reset(parser_: Parser) callconv(.c) void { + parser_.?.reset(); +} + +pub fn next(parser_: Parser, byte: u8) callconv(.c) void { + parser_.?.next(byte); +} + test "osc" { const testing = std.testing; var p: Parser = undefined; diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index bd7337b42..5b0ea0847 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -431,7 +431,7 @@ pub const Parser = struct { self.reset(); } - /// Reset the parser start. + /// Reset the parser state. pub fn reset(self: *Parser) void { // If the state is already empty then we do nothing because // we may touch uninitialized memory. From a79e68ace105815f45a52ee0b4ff03bf5558fa0b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 14:25:00 -0700 Subject: [PATCH 28/52] lib: enum func --- src/lib/enum.zig | 85 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/main.zig | 10 ++++++ src/lib_vt.zig | 3 +- 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/lib/enum.zig create mode 100644 src/lib/main.zig diff --git a/src/lib/enum.zig b/src/lib/enum.zig new file mode 100644 index 000000000..01006f46f --- /dev/null +++ b/src/lib/enum.zig @@ -0,0 +1,85 @@ +const std = @import("std"); + +/// Create an enum type with the given keys that is C ABI compatible +/// if we're targeting C, otherwise a Zig enum with smallest possible +/// backing type. +/// +/// In all cases, the enum keys will be created in the order given. +/// For C ABI, this means that the order MUST NOT be changed in order +/// to preserve ABI compatibility. You can set a key to null to +/// remove it from the Zig enum while keeping the "hole" in the C enum +/// to preserve ABI compatibility. +/// +/// C detection is up to the caller, since there are multiple ways +/// to do that. We rely on the `target` parameter to determine whether we +/// should create a C compatible enum or a Zig enum. +/// +/// For the Zig enum, the enum value is not guaranteed to be stable, so +/// it shouldn't be relied for things like serialization. +pub fn Enum( + target: Target, + keys: []const ?[:0]const u8, +) type { + var fields: [keys.len]std.builtin.Type.EnumField = undefined; + var fields_i: usize = 0; + for (keys, 0..) |key_, key_i| { + const key: [:0]const u8 = key_ orelse switch (target) { + .c => std.fmt.comptimePrint("__unused_{d}", .{key_i}), + .zig => continue, + }; + + fields[fields_i] = .{ + .name = key, + .value = fields_i, + }; + fields_i += 1; + } + + return @Type(.{ .@"enum" = .{ + .tag_type = switch (target) { + .c => c_int, + .zig => std.math.IntFittingRange(0, fields_i - 1), + }, + .fields = fields[0..fields_i], + .decls = &.{}, + .is_exhaustive = true, + } }); +} + +pub const Target = union(enum) { + c, + zig, +}; + +test "zig" { + const testing = std.testing; + const T = Enum(.zig, &.{ "a", "b", "c", "d" }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(u2, info.tag_type); +} + +test "c" { + const testing = std.testing; + const T = Enum(.c, &.{ "a", "b", "c", "d" }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(c_int, info.tag_type); +} + +test "abi by removing a key" { + const testing = std.testing; + // C + { + const T = Enum(.c, &.{ "a", "b", null, "d" }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(c_int, info.tag_type); + try testing.expectEqual(3, @intFromEnum(T.d)); + } + + // Zig + { + const T = Enum(.zig, &.{ "a", "b", null, "d" }); + const info = @typeInfo(T).@"enum"; + try testing.expectEqual(u2, info.tag_type); + try testing.expectEqual(2, @intFromEnum(T.d)); + } +} diff --git a/src/lib/main.zig b/src/lib/main.zig new file mode 100644 index 000000000..4ef8dcb2d --- /dev/null +++ b/src/lib/main.zig @@ -0,0 +1,10 @@ +const std = @import("std"); +const enumpkg = @import("enum.zig"); + +pub const allocator = @import("allocator.zig"); +pub const Enum = enumpkg.Enum; +pub const EnumTarget = enumpkg.Target; + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 63a84ad63..6d9c042d8 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -80,6 +80,7 @@ comptime { test { _ = terminal; - // Tests always test the C API + // Tests always test the C API and shared C functions _ = terminal.c_api; + _ = @import("lib/main.zig"); } From 397e47c274fb5077e16b37f28c8f9327cc995b2e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 14:32:45 -0700 Subject: [PATCH 29/52] terminal: use LibEnum for the command keys --- src/lib/enum.zig | 4 +++- src/terminal/build_options.zig | 3 +++ src/terminal/main.zig | 2 +- src/terminal/osc.zig | 32 +++++++++++++++++++++++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/lib/enum.zig b/src/lib/enum.zig index 01006f46f..063232176 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -35,7 +35,8 @@ pub fn Enum( fields_i += 1; } - return @Type(.{ .@"enum" = .{ + // Assigned to var so that the type name is nicer in stack traces. + const Result = @Type(.{ .@"enum" = .{ .tag_type = switch (target) { .c => c_int, .zig => std.math.IntFittingRange(0, fields_i - 1), @@ -44,6 +45,7 @@ pub fn Enum( .decls = &.{}, .is_exhaustive = true, } }); + return Result; } pub const Target = union(enum) { diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 1b0449bbf..2085e2243 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -1,5 +1,8 @@ const std = @import("std"); +/// True if we're building the C library libghostty-vt. +pub const is_c_lib = @import("root") == @import("../lib_vt.zig"); + pub const Options = struct { /// The target artifact to build. This will gate some functionality. artifact: Artifact, diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 832fe6a29..70b5742cd 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -63,7 +63,7 @@ pub const Attribute = sgr.Attribute; pub const isSafePaste = sanitize.isSafePaste; /// This is set to true when we're building the C library. -pub const is_c_lib = @import("root") == @import("../lib_vt.zig"); +pub const is_c_lib = @import("build_options.zig").is_c_lib; pub const c_api = @import("c/main.zig"); test { diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 5b0ea0847..9ba394c67 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -10,6 +10,8 @@ const builtin = @import("builtin"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; +const LibEnum = @import("../lib/enum.zig").Enum; +const is_c_lib = @import("build_options.zig").is_c_lib; const RGB = @import("color.zig").RGB; const kitty_color = @import("kitty/color.zig"); const osc_color = @import("osc/color.zig"); @@ -17,7 +19,7 @@ pub const color = osc_color; const log = std.log.scoped(.osc); -pub const Command = union(enum) { +pub const Command = union(Key) { /// This generally shouldn't ever be set except as an initial zero value. /// Ignore it. invalid, @@ -172,6 +174,34 @@ pub const Command = union(enum) { /// ConEmu GUI macro (OSC 9;6) conemu_guimacro: []const u8, + pub const Key = LibEnum( + if (is_c_lib) .c else .zig, + // NOTE: Order matters, see LibEnum documentation. + &.{ + "invalid", + "change_window_title", + "change_window_icon", + "prompt_start", + "prompt_end", + "end_of_input", + "end_of_command", + "clipboard_contents", + "report_pwd", + "mouse_shape", + "color_operation", + "kitty_color_protocol", + "show_desktop_notification", + "hyperlink_start", + "hyperlink_end", + "conemu_sleep", + "conemu_show_message_box", + "conemu_change_tab_title", + "conemu_progress_report", + "conemu_wait_input", + "conemu_guimacro", + }, + ); + pub const ProgressReport = struct { pub const State = enum(c_int) { remove, From 6a0a94c82728d93440fc6f693154ec5aa4c79dc9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 14:42:59 -0700 Subject: [PATCH 30/52] lib: fix holes handling for C --- src/lib/enum.zig | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/lib/enum.zig b/src/lib/enum.zig index 063232176..c3971ebde 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -22,15 +22,25 @@ pub fn Enum( ) type { var fields: [keys.len]std.builtin.Type.EnumField = undefined; var fields_i: usize = 0; - for (keys, 0..) |key_, key_i| { - const key: [:0]const u8 = key_ orelse switch (target) { - .c => std.fmt.comptimePrint("__unused_{d}", .{key_i}), - .zig => continue, + var holes: usize = 0; + for (keys) |key_| { + const key: [:0]const u8 = key_ orelse { + switch (target) { + // For Zig we don't track holes because the enum value + // isn't guaranteed to be stable and we want to use the + // smallest possible backing type. + .zig => {}, + + // For C we must track holes to preserve ABI compatibility + // with subsequent values. + .c => holes += 1, + } + continue; }; fields[fields_i] = .{ .name = key, - .value = fields_i, + .value = fields_i + holes, }; fields_i += 1; } From 6b1f4088dd15ccf8377ba778554421ec61b30ce0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 14:57:51 -0700 Subject: [PATCH 31/52] lib-vt: add the C functions for command inspection --- include/ghostty/vt.h | 53 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 657a9e60f..c784dcb0e 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -35,6 +35,15 @@ extern "C" { */ typedef struct GhosttyOscParser *GhosttyOscParser; +/** + * Opaque handle to a single OSC command. + * + * This handle represents a parsed OSC (Operating System Command) command. + * The command can be queried for its type and associated data using + * `ghostty_osc_command_type` and `ghostty_osc_command_data`. + */ +typedef struct GhosttyOscCommand *GhosttyOscCommand; + /** * Result codes for libghostty-vt operations. */ @@ -45,6 +54,46 @@ typedef enum { GHOSTTY_OUT_OF_MEMORY = -1, } GhosttyResult; +/** + * OSC command types. + */ +typedef enum { + GHOSTTY_OSC_COMMAND_INVALID = 0, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1, + GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2, + GHOSTTY_OSC_COMMAND_PROMPT_START = 3, + GHOSTTY_OSC_COMMAND_PROMPT_END = 4, + GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5, + GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6, + GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7, + GHOSTTY_OSC_COMMAND_REPORT_PWD = 8, + GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9, + GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10, + GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11, + GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12, + GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13, + GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14, + GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15, + GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16, + GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17, + GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18, + GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19, + GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, +} GhosttyOscCommandType; + +/** + * OSC command data types. The values returned are documented + * on each type. + * */ +typedef enum { + /** + * The window title string. + * + * Type: const char* + * */ + GHOSTTY_OSC_DATA_WINDOW_TITLE, +} GhosttyOscCommandData; + //------------------------------------------------------------------- // Allocator Interface @@ -240,6 +289,10 @@ void ghostty_osc_reset(GhosttyOscParser parser); */ void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); +GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser); +GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); +bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData, void *result); + #ifdef __cplusplus } #endif From cc0f2e79cd75add2cb2b82a0372c92fc4fb4b4c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 15:05:38 -0700 Subject: [PATCH 32/52] terminal: osc parser end returns a pointer --- src/terminal/Parser.zig | 2 +- src/terminal/osc.zig | 157 ++++++++++++++++++++-------------------- 2 files changed, 81 insertions(+), 78 deletions(-) diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 1f2e814f6..6deb03da5 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -274,7 +274,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action { // Exit depends on current state if (self.state == next_state) null else switch (self.state) { .osc_string => if (self.osc_parser.end(c)) |cmd| - Action{ .osc_dispatch = cmd } + Action{ .osc_dispatch = cmd.* } else null, .dcs_passthrough => Action{ .dcs_unhook = {} }, diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 9ba394c67..71d2f8598 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1597,7 +1597,10 @@ pub const Parser = struct { /// is null, then no valid command was found. The optional terminator_ch /// is the final character in the OSC sequence. This is used to determine /// the response terminator. - pub fn end(self: *Parser, terminator_ch: ?u8) ?Command { + /// + /// The returned pointer is only valid until the next call to the parser. + /// Callers should copy out any data they wish to retain across calls. + pub fn end(self: *Parser, terminator_ch: ?u8) ?*Command { if (!self.complete) { if (comptime !builtin.is_test) log.warn( "invalid OSC command: {s}", @@ -1656,7 +1659,7 @@ pub const Parser = struct { else => {}, } - return self.command; + return &self.command; } }; @@ -1672,7 +1675,7 @@ test "OSC: change_window_title" { p.next(';'); p.next('a'); p.next('b'); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } @@ -1685,7 +1688,7 @@ test "OSC: change_window_title with 2" { p.next(';'); p.next('a'); p.next('b'); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("ab", cmd.change_window_title); } @@ -1707,7 +1710,7 @@ test "OSC: change_window_title with utf8" { p.next(0xE2); p.next(0x80); p.next(0x90); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("— ‐", cmd.change_window_title); } @@ -1718,7 +1721,7 @@ test "OSC: change_window_title empty" { var p: Parser = .init(); p.next('2'); p.next(';'); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .change_window_title); try testing.expectEqualStrings("", cmd.change_window_title); } @@ -1731,7 +1734,7 @@ test "OSC: change_window_icon" { p.next(';'); p.next('a'); p.next('b'); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .change_window_icon); try testing.expectEqualStrings("ab", cmd.change_window_icon); } @@ -1744,7 +1747,7 @@ test "OSC: prompt_start" { const input = "133;A"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.aid == null); try testing.expect(cmd.prompt_start.redraw); @@ -1758,7 +1761,7 @@ test "OSC: prompt_start with single option" { const input = "133;A;aid=14"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expectEqualStrings("14", cmd.prompt_start.aid.?); } @@ -1771,7 +1774,7 @@ test "OSC: prompt_start with redraw disabled" { const input = "133;A;redraw=0"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expect(!cmd.prompt_start.redraw); } @@ -1784,7 +1787,7 @@ test "OSC: prompt_start with redraw invalid value" { const input = "133;A;redraw=42"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.redraw); try testing.expect(cmd.prompt_start.kind == .primary); @@ -1798,7 +1801,7 @@ test "OSC: prompt_start with continuation" { const input = "133;A;k=c"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.kind == .continuation); } @@ -1811,7 +1814,7 @@ test "OSC: prompt_start with secondary" { const input = "133;A;k=s"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_start); try testing.expect(cmd.prompt_start.kind == .secondary); } @@ -1824,7 +1827,7 @@ test "OSC: end_of_command no exit code" { const input = "133;D"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_command); } @@ -1836,7 +1839,7 @@ test "OSC: end_of_command with exit code" { const input = "133;D;25"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_command); try testing.expectEqual(@as(u8, 25), cmd.end_of_command.exit_code.?); } @@ -1849,7 +1852,7 @@ test "OSC: prompt_end" { const input = "133;B"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .prompt_end); } @@ -1861,7 +1864,7 @@ test "OSC: end_of_input" { const input = "133;C"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .end_of_input); } @@ -1873,7 +1876,7 @@ test "OSC: get/set clipboard" { const input = "52;s;?"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expectEqualStrings("?", cmd.clipboard_contents.data); @@ -1887,7 +1890,7 @@ test "OSC: get/set clipboard (optional parameter)" { const input = "52;;?"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 'c'); try testing.expectEqualStrings("?", cmd.clipboard_contents.data); @@ -1902,7 +1905,7 @@ test "OSC: get/set clipboard with allocator" { const input = "52;s;?"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expectEqualStrings("?", cmd.clipboard_contents.data); @@ -1917,7 +1920,7 @@ test "OSC: clear clipboard" { const input = "52;;"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 'c'); try testing.expectEqualStrings("", cmd.clipboard_contents.data); @@ -1931,7 +1934,7 @@ test "OSC: report pwd" { const input = "7;file:///tmp/example"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .report_pwd); try testing.expectEqualStrings("file:///tmp/example", cmd.report_pwd.value); } @@ -1943,7 +1946,7 @@ test "OSC: report pwd empty" { const input = "7;"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .report_pwd); try testing.expectEqualStrings("", cmd.report_pwd.value); } @@ -1956,7 +1959,7 @@ test "OSC: pointer cursor" { const input = "22;pointer"; for (input) |ch| p.next(ch); - const cmd = p.end(null).?; + const cmd = p.end(null).?.*; try testing.expect(cmd == .mouse_shape); try testing.expectEqualStrings("pointer", cmd.mouse_shape.value); } @@ -1981,7 +1984,7 @@ test "OSC: OSC 9;1 ConEmu sleep" { const input = "9;1;420"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_sleep); try testing.expectEqual(420, cmd.conemu_sleep.duration_ms); @@ -1995,7 +1998,7 @@ test "OSC: OSC 9;1 ConEmu sleep with no value default to 100ms" { const input = "9;1;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_sleep); try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); @@ -2009,7 +2012,7 @@ test "OSC: OSC 9;1 conemu sleep cannot exceed 10000ms" { const input = "9;1;12345"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_sleep); try testing.expectEqual(10000, cmd.conemu_sleep.duration_ms); @@ -2023,7 +2026,7 @@ test "OSC: OSC 9;1 conemu sleep invalid input" { const input = "9;1;foo"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_sleep); try testing.expectEqual(100, cmd.conemu_sleep.duration_ms); @@ -2037,7 +2040,7 @@ test "OSC: OSC 9;1 conemu sleep -> desktop notification 1" { const input = "9;1"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("1", cmd.show_desktop_notification.body); @@ -2051,7 +2054,7 @@ test "OSC: OSC 9;1 conemu sleep -> desktop notification 2" { const input = "9;1a"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("1a", cmd.show_desktop_notification.body); @@ -2065,7 +2068,7 @@ test "OSC: OSC 9 show desktop notification" { const input = "9;Hello world"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("", cmd.show_desktop_notification.title); try testing.expectEqualStrings("Hello world", cmd.show_desktop_notification.body); @@ -2079,7 +2082,7 @@ test "OSC: OSC 9 show single character desktop notification" { const input = "9;H"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("", cmd.show_desktop_notification.title); try testing.expectEqualStrings("H", cmd.show_desktop_notification.body); @@ -2093,7 +2096,7 @@ test "OSC: OSC 777 show desktop notification with title" { const input = "777;notify;Title;Body"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); @@ -2107,7 +2110,7 @@ test "OSC: OSC 9;2 ConEmu message box" { const input = "9;2;hello world"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_show_message_box); try testing.expectEqualStrings("hello world", cmd.conemu_show_message_box); } @@ -2120,7 +2123,7 @@ test "OSC: 9;2 ConEmu message box invalid input" { const input = "9;2"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); } @@ -2133,7 +2136,7 @@ test "OSC: 9;2 ConEmu message box empty message" { const input = "9;2;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_show_message_box); try testing.expectEqualStrings("", cmd.conemu_show_message_box); } @@ -2146,7 +2149,7 @@ test "OSC: 9;2 ConEmu message box spaces only message" { const input = "9;2; "; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_show_message_box); try testing.expectEqualStrings(" ", cmd.conemu_show_message_box); } @@ -2159,7 +2162,7 @@ test "OSC: OSC 9;2 message box -> desktop notification 1" { const input = "9;2"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("2", cmd.show_desktop_notification.body); @@ -2173,7 +2176,7 @@ test "OSC: OSC 9;2 message box -> desktop notification 2" { const input = "9;2a"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("2a", cmd.show_desktop_notification.body); @@ -2187,7 +2190,7 @@ test "OSC: 9;3 ConEmu change tab title" { const input = "9;3;foo bar"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_change_tab_title); try testing.expectEqualStrings("foo bar", cmd.conemu_change_tab_title.value); } @@ -2200,7 +2203,7 @@ test "OSC: 9;3 ConEmu change tab title reset" { const input = "9;3;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; const expected_command: Command = .{ .conemu_change_tab_title = .reset }; try testing.expectEqual(expected_command, cmd); @@ -2214,7 +2217,7 @@ test "OSC: 9;3 ConEmu change tab title spaces only" { const input = "9;3; "; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_change_tab_title); try testing.expectEqualStrings(" ", cmd.conemu_change_tab_title.value); @@ -2228,7 +2231,7 @@ test "OSC: OSC 9;3 change tab title -> desktop notification 1" { const input = "9;3"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("3", cmd.show_desktop_notification.body); @@ -2242,7 +2245,7 @@ test "OSC: OSC 9;3 message box -> desktop notification 2" { const input = "9;3a"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("3a", cmd.show_desktop_notification.body); @@ -2256,7 +2259,7 @@ test "OSC: OSC 9;4 ConEmu progress set" { const input = "9;4;1;100"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .set); try testing.expect(cmd.conemu_progress_report.progress == 100); @@ -2270,7 +2273,7 @@ test "OSC: OSC 9;4 ConEmu progress set overflow" { const input = "9;4;1;900"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .set); try testing.expectEqual(100, cmd.conemu_progress_report.progress); @@ -2284,7 +2287,7 @@ test "OSC: OSC 9;4 ConEmu progress set single digit" { const input = "9;4;1;9"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .set); try testing.expect(cmd.conemu_progress_report.progress == 9); @@ -2298,7 +2301,7 @@ test "OSC: OSC 9;4 ConEmu progress set double digit" { const input = "9;4;1;94"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .set); try testing.expectEqual(94, cmd.conemu_progress_report.progress); @@ -2312,7 +2315,7 @@ test "OSC: OSC 9;4 ConEmu progress set extra semicolon ignored" { const input = "9;4;1;100"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .set); try testing.expectEqual(100, cmd.conemu_progress_report.progress); @@ -2326,7 +2329,7 @@ test "OSC: OSC 9;4 ConEmu progress remove with no progress" { const input = "9;4;0;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .remove); try testing.expect(cmd.conemu_progress_report.progress == null); @@ -2340,7 +2343,7 @@ test "OSC: OSC 9;4 ConEmu progress remove with double semicolon" { const input = "9;4;0;;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .remove); try testing.expect(cmd.conemu_progress_report.progress == null); @@ -2354,7 +2357,7 @@ test "OSC: OSC 9;4 ConEmu progress remove ignores progress" { const input = "9;4;0;100"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .remove); try testing.expect(cmd.conemu_progress_report.progress == null); @@ -2368,7 +2371,7 @@ test "OSC: OSC 9;4 ConEmu progress remove extra semicolon" { const input = "9;4;0;100;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .remove); } @@ -2381,7 +2384,7 @@ test "OSC: OSC 9;4 ConEmu progress error" { const input = "9;4;2"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .@"error"); try testing.expect(cmd.conemu_progress_report.progress == null); @@ -2395,7 +2398,7 @@ test "OSC: OSC 9;4 ConEmu progress error with progress" { const input = "9;4;2;100"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .@"error"); try testing.expect(cmd.conemu_progress_report.progress == 100); @@ -2409,7 +2412,7 @@ test "OSC: OSC 9;4 progress pause" { const input = "9;4;4"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .pause); try testing.expect(cmd.conemu_progress_report.progress == null); @@ -2423,7 +2426,7 @@ test "OSC: OSC 9;4 ConEmu progress pause with progress" { const input = "9;4;4;100"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_progress_report); try testing.expect(cmd.conemu_progress_report.state == .pause); try testing.expect(cmd.conemu_progress_report.progress == 100); @@ -2437,7 +2440,7 @@ test "OSC: OSC 9;4 progress -> desktop notification 1" { const input = "9;4"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("4", cmd.show_desktop_notification.body); @@ -2451,7 +2454,7 @@ test "OSC: OSC 9;4 progress -> desktop notification 2" { const input = "9;4;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("4;", cmd.show_desktop_notification.body); @@ -2465,7 +2468,7 @@ test "OSC: OSC 9;4 progress -> desktop notification 3" { const input = "9;4;5"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("4;5", cmd.show_desktop_notification.body); @@ -2479,7 +2482,7 @@ test "OSC: OSC 9;4 progress -> desktop notification 4" { const input = "9;4;5a"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("4;5a", cmd.show_desktop_notification.body); @@ -2493,7 +2496,7 @@ test "OSC: OSC 9;5 ConEmu wait input" { const input = "9;5"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_wait_input); } @@ -2505,7 +2508,7 @@ test "OSC: OSC 9;5 ConEmu wait ignores trailing characters" { const input = "9;5;foo"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_wait_input); } @@ -2529,7 +2532,7 @@ test "OSC: hyperlink" { const input = "8;;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } @@ -2542,7 +2545,7 @@ test "OSC: hyperlink with id set" { const input = "8;id=foo;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); @@ -2556,7 +2559,7 @@ test "OSC: hyperlink with empty id" { const input = "8;id=;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqual(null, cmd.hyperlink_start.id); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); @@ -2570,7 +2573,7 @@ test "OSC: hyperlink with incomplete key" { const input = "8;id;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqual(null, cmd.hyperlink_start.id); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); @@ -2584,7 +2587,7 @@ test "OSC: hyperlink with empty key" { const input = "8;=value;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqual(null, cmd.hyperlink_start.id); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); @@ -2598,7 +2601,7 @@ test "OSC: hyperlink with empty key and id" { const input = "8;=value:id=foo;http://example.com"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_start); try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); @@ -2624,7 +2627,7 @@ test "OSC: hyperlink end" { const input = "8;;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .hyperlink_end); } @@ -2638,7 +2641,7 @@ test "OSC: kitty color protocol" { const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_color_protocol); try testing.expectEqual(@as(usize, 9), cmd.kitty_color_protocol.list.items.len); { @@ -2720,7 +2723,7 @@ test "OSC: kitty color protocol double reset" { const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_color_protocol); p.reset(); @@ -2736,7 +2739,7 @@ test "OSC: kitty color protocol reset after invalid" { const input = "21;foreground=?;background=rgb:f0/f8/ff;cursor=aliceblue;cursor_text;visual_bell=;selection_foreground=#xxxyyzz;selection_background=?;selection_background=#aabbcc;2=?;3=rgbi:1.0/1.0/1.0"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_color_protocol); p.reset(); @@ -2757,7 +2760,7 @@ test "OSC: kitty color protocol no key" { const input = "21;"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_color_protocol); try testing.expectEqual(0, cmd.kitty_color_protocol.list.items.len); } @@ -2771,7 +2774,7 @@ test "OSC: 9;6: ConEmu guimacro 1" { const input = "9;6;a"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_guimacro); try testing.expectEqualStrings("a", cmd.conemu_guimacro); } @@ -2785,7 +2788,7 @@ test "OSC: 9;6: ConEmu guimacro 2" { const input = "9;6;ab"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .conemu_guimacro); try testing.expectEqualStrings("ab", cmd.conemu_guimacro); } @@ -2799,7 +2802,7 @@ test "OSC: 9;6: ConEmu guimacro 3 incomplete -> desktop notification" { const input = "9;6"; for (input) |ch| p.next(ch); - const cmd = p.end('\x1b').?; + const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .show_desktop_notification); try testing.expectEqualStrings("6", cmd.show_desktop_notification.body); } From f564ffa30b8b78ec9f480864edda96e4b63051c7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 27 Sep 2025 15:15:11 -0700 Subject: [PATCH 33/52] lib-vt: expose ghostty_osc_end --- include/ghostty/vt.h | 34 +++++++++++++++++++++++++++++++--- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/osc.zig | 7 +++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index c784dcb0e..fc5eb1812 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -289,9 +289,37 @@ void ghostty_osc_reset(GhosttyOscParser parser); */ void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); -GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser); -GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); -bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData, void *result); +/** + * Finalize OSC parsing and retrieve the parsed command. + * + * Call this function after feeding all bytes of an OSC sequence to the parser + * using ghostty_osc_next() with the exception of the terminating character + * (ESC or ST). This function finalizes the parsing process and returns the + * parsed OSC command. + * + * The return value is never NULL. Invalid commands will return a command + * with type GHOSTTY_OSC_COMMAND_INVALID. + * + * The terminator parameter specifies the byte that terminated the OSC sequence + * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is + * preserved in the parsed command so that responses can use the same terminator + * format for better compatibility with the calling program. For commands that + * do not require a response, this parameter is ignored and the resulting + * command will not retain the terminator information. + * + * The returned command handle is valid until the next call to any + * `ghostty_osc_*` function with the same parser instance with the exception + * of command introspection functions such as `ghostty_osc_command_type`. + * + * @param parser The parser handle, must not be null. + * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST) + * @return Handle to the parsed OSC command + */ +GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); + +// TODO +// GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); +// bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData, void *result); #ifdef __cplusplus } diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 6d9c042d8..4de7e390e 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -74,6 +74,7 @@ comptime { @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); + @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1a25685ba..2779beebd 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -5,6 +5,7 @@ pub const osc_new = osc.new; pub const osc_free = osc.free; pub const osc_reset = osc.reset; pub const osc_next = osc.next; +pub const osc_end = osc.end; test { _ = osc; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 59761cfba..3859b3cc3 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -9,6 +9,9 @@ const Result = @import("result.zig").Result; /// C: GhosttyOscParser pub const Parser = ?*osc.Parser; +/// C: GhosttyOscCommand +pub const Command = ?*osc.Command; + pub fn new( alloc_: ?*const CAllocator, result: *Parser, @@ -37,6 +40,10 @@ pub fn next(parser_: Parser, byte: u8) callconv(.c) void { parser_.?.next(byte); } +pub fn end(parser_: Parser, terminator: u8) callconv(.c) Command { + return parser_.?.end(terminator); +} + test "osc" { const testing = std.testing; var p: Parser = undefined; From 89fc7139ae18591191d55c6dee276aa5e1153eae Mon Sep 17 00:00:00 2001 From: mitchellh <1299+mitchellh@users.noreply.github.com> Date: Sun, 28 Sep 2025 00:15:36 +0000 Subject: [PATCH 34/52] deps: Update iTerm2 color schemes --- build.zig.zon | 4 ++-- build.zig.zon.json | 6 +++--- build.zig.zon.nix | 6 +++--- build.zig.zon.txt | 2 +- flatpak/zig-packages.json | 6 +++--- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index 028f1a0d6..70bc28b75 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250916-134637-76894f0/ghostty-themes.tgz", - .hash = "N-V-__8AAGsjAwAxRB3Y9Akv_HeLfvJA-tIqW6ACnBhWosM3", + .url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250922-150534-d28055b/ghostty-themes.tgz", + .hash = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index 702df5026..e33d29164 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -49,10 +49,10 @@ "url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz", "hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA=" }, - "N-V-__8AAGsjAwAxRB3Y9Akv_HeLfvJA-tIqW6ACnBhWosM3": { + "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv": { "name": "iterm2_themes", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250916-134637-76894f0/ghostty-themes.tgz", - "hash": "sha256-JPY9M50d/n6rGzWt0aQZIU7IBMWru2IAqe9Vu1x5CMw=" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250922-150534-d28055b/ghostty-themes.tgz", + "hash": "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index c38b34e4d..513badffd 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -163,11 +163,11 @@ in }; } { - name = "N-V-__8AAGsjAwAxRB3Y9Akv_HeLfvJA-tIqW6ACnBhWosM3"; + name = "N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250916-134637-76894f0/ghostty-themes.tgz"; - hash = "sha256-JPY9M50d/n6rGzWt0aQZIU7IBMWru2IAqe9Vu1x5CMw="; + url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250922-150534-d28055b/ghostty-themes.tgz"; + hash = "sha256-mdhUxAAqKxRRXwED2laabUo9ZZqZa/MZAsO0+Y9L7uQ="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 1caa7b000..eefb990e4 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -28,7 +28,7 @@ https://deps.files.ghostty.org/zig_js-12205a66d423259567764fa0fc60c82be35365c21a https://deps.files.ghostty.org/ziglyph-b89d43d1e3fb01b6074bc1f7fc980324b04d26a5.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz https://github.com/ghostty-org/zig-gobject/releases/download/2025-09-20-20-1/ghostty-gobject-2025-09-20-20-1.tar.zst -https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250916-134637-76894f0/ghostty-themes.tgz +https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250922-150534-d28055b/ghostty-themes.tgz https://github.com/mitchellh/libxev/archive/7f803181b158a10fec8619f793e3b4df515566cb.tar.gz https://github.com/mitchellh/zig-objc/archive/c9e917a4e15a983b672ca779c7985d738a2d517c.tar.gz https://github.com/natecraddock/zf/archive/7aacbe6d155d64d15937ca95ca6c014905eb531f.tar.gz diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 46f6c950d..a5723ff38 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -61,9 +61,9 @@ }, { "type": "archive", - "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250916-134637-76894f0/ghostty-themes.tgz", - "dest": "vendor/p/N-V-__8AAGsjAwAxRB3Y9Akv_HeLfvJA-tIqW6ACnBhWosM3", - "sha256": "24f63d339d1dfe7eab1b35add1a419214ec804c5abbb6200a9ef55bb5c7908cc" + "url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20250922-150534-d28055b/ghostty-themes.tgz", + "dest": "vendor/p/N-V-__8AACEnAwCkyrJ_XqdomYlR8bpuh0l8WDidEz-BAzIv", + "sha256": "99d854c4002a2b14515f0103da569a6d4a3d659a996bf31902c3b4f98f4beee4" }, { "type": "archive", From cfe9f194542c521c51a3d1ab6f563fe5b318da77 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Sep 2025 07:15:34 -0700 Subject: [PATCH 35/52] lib-vt: expose command type enum --- include/ghostty/vt.h | 27 +++++++++++---------------- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/osc.zig | 28 +++++++++++++++++++++++++++- src/terminal/main.zig | 2 +- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index fc5eb1812..5d80cb653 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -81,19 +81,6 @@ typedef enum { GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, } GhosttyOscCommandType; -/** - * OSC command data types. The values returned are documented - * on each type. - * */ -typedef enum { - /** - * The window title string. - * - * Type: const char* - * */ - GHOSTTY_OSC_DATA_WINDOW_TITLE, -} GhosttyOscCommandData; - //------------------------------------------------------------------- // Allocator Interface @@ -317,9 +304,17 @@ void ghostty_osc_next(GhosttyOscParser parser, uint8_t byte); */ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); -// TODO -// GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); -// bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData, void *result); +/** + * Get the type of an OSC command. + * + * Returns the type identifier for the given OSC command. This can be used + * to determine what kind of command was parsed and what data might be + * available from it. + * + * @param command The OSC command handle to query (may be NULL) + * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL + */ +GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); #ifdef __cplusplus } diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 4de7e390e..37ab7ae68 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -75,6 +75,7 @@ comptime { @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); + @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 2779beebd..f32dd226f 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -6,6 +6,7 @@ pub const osc_free = osc.free; pub const osc_reset = osc.reset; pub const osc_next = osc.next; pub const osc_end = osc.end; +pub const osc_command_type = osc.commandType; test { _ = osc; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index 3859b3cc3..c04626b69 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -44,7 +44,12 @@ pub fn end(parser_: Parser, terminator: u8) callconv(.c) Command { return parser_.?.end(terminator); } -test "osc" { +pub fn commandType(command_: Command) callconv(.c) osc.Command.Key { + const command = command_ orelse return .invalid; + return command.*; +} + +test "alloc" { const testing = std.testing; var p: Parser = undefined; try testing.expectEqual(Result.success, new( @@ -53,3 +58,24 @@ test "osc" { )); free(p); } + +test "command type null" { + const testing = std.testing; + try testing.expectEqual(.invalid, commandType(null)); +} + +test "command type" { + const testing = std.testing; + var p: Parser = undefined; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &p, + )); + defer free(p); + + p.next('0'); + p.next(';'); + p.next('a'); + const cmd = p.end(0); + try testing.expectEqual(.change_window_title, commandType(cmd)); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 70b5742cd..7403ff309 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -64,7 +64,7 @@ pub const isSafePaste = sanitize.isSafePaste; /// This is set to true when we're building the C library. pub const is_c_lib = @import("build_options.zig").is_c_lib; -pub const c_api = @import("c/main.zig"); +pub const c_api = if (is_c_lib) @import("c/main.zig") else void; test { @import("std").testing.refAllDecls(@This()); From a76297058fd2da5c8452dcaf356dfcb6343e1574 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Sep 2025 07:24:08 -0700 Subject: [PATCH 36/52] example/c-vt: parse a full OSC command --- example/c-vt/src/main.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/example/c-vt/src/main.c b/example/c-vt/src/main.c index 1eaa659d2..00ea3618f 100644 --- a/example/c-vt/src/main.c +++ b/example/c-vt/src/main.c @@ -1,4 +1,5 @@ #include +#include #include int main() { @@ -6,6 +7,19 @@ int main() { if (ghostty_osc_new(NULL, &parser) != GHOSTTY_SUCCESS) { return 1; } + + // Setup change window title command to change the title to "a" + ghostty_osc_next(parser, '0'); + ghostty_osc_next(parser, ';'); + ghostty_osc_next(parser, 'a'); + + // End parsing and get command + GhosttyOscCommand command = ghostty_osc_end(parser, 0); + + // Get and print command type + GhosttyOscCommandType type = ghostty_osc_command_type(command); + printf("Command type: %d\n", type); + ghostty_osc_free(parser); return 0; } From f614fb7c1b0bcf0f7256aea0040ec5425be09929 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Sep 2025 13:59:13 -0700 Subject: [PATCH 37/52] build: use build options to configure terminal C ABI mode Fixes various issues: - C ABI detection was faulty, which caused some Zig programs to use the C ABI mode and some C programs not to. Let's be explicit. - Unit tests now tests C ABI mode. - Build binary no longer rebuilds on any terminal change (a regression). - Zig programs can choose to depend on the C ABI version of the terminal lib by using the `ghostty-vt-c` module. --- build.zig | 9 +++++++ src/build/Config.zig | 1 + src/build/GhosttyLibVt.zig | 2 +- src/build/GhosttyZig.zig | 45 +++++++++++++++++++++++++++++----- src/lib_vt.zig | 9 ++++--- src/terminal/build_options.zig | 9 ++++--- src/terminal/c/osc.zig | 8 +++--- src/terminal/main.zig | 9 ++++--- src/terminal/osc.zig | 4 +-- 9 files changed, 72 insertions(+), 24 deletions(-) diff --git a/build.zig b/build.zig index 008fc849e..62fa77511 100644 --- a/build.zig +++ b/build.zig @@ -255,6 +255,15 @@ pub fn build(b: *std.Build) !void { }); const mod_vt_test_run = b.addRunArtifact(mod_vt_test); test_lib_vt_step.dependOn(&mod_vt_test_run.step); + + const mod_vt_c_test = b.addTest(.{ + .root_module = mod.vt_c, + .target = config.target, + .optimize = config.optimize, + .filters = test_filters, + }); + const mod_vt_c_test_run = b.addRunArtifact(mod_vt_c_test); + test_lib_vt_step.dependOn(&mod_vt_c_test_run.step); } // Tests diff --git a/src/build/Config.zig b/src/build/Config.zig index 474674d3a..0b7dae14d 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -498,6 +498,7 @@ pub fn terminalOptions(self: *const Config) TerminalBuildOptions { .artifact = .ghostty, .simd = self.simd, .oniguruma = true, + .c_abi = false, .slow_runtime_safety = switch (self.optimize) { .Debug => true, .ReleaseSafe, diff --git a/src/build/GhosttyLibVt.zig b/src/build/GhosttyLibVt.zig index 0029d6756..80f2bf9ad 100644 --- a/src/build/GhosttyLibVt.zig +++ b/src/build/GhosttyLibVt.zig @@ -26,7 +26,7 @@ pub fn initShared( const target = zig.vt.resolved_target.?; const lib = b.addSharedLibrary(.{ .name = "ghostty-vt", - .root_module = zig.vt, + .root_module = zig.vt_c, }); lib.installHeader( b.path("include/ghostty/vt.h"), diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index f175eb957..a8d2726bc 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -5,18 +5,17 @@ const GhosttyZig = @This(); const std = @import("std"); const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); +const TerminalBuildOptions = @import("../terminal/build_options.zig").Options; +/// The `_c`-suffixed modules are built with the C ABI enabled. vt: *std.Build.Module, +vt_c: *std.Build.Module, pub fn init( b: *std.Build, cfg: *const Config, deps: *const SharedDeps, ) !GhosttyZig { - // General build options - const general_options = b.addOptions(); - try cfg.addOptions(general_options); - // Terminal module build options var vt_options = cfg.terminalOptions(); vt_options.artifact = .lib; @@ -25,7 +24,41 @@ pub fn init( // conditionally do this. vt_options.oniguruma = false; - const vt = b.addModule("ghostty-vt", .{ + return .{ + .vt = try initVt( + "ghostty-vt", + b, + cfg, + deps, + vt_options, + ), + + .vt_c = try initVt( + "ghostty-vt-c", + b, + cfg, + deps, + options: { + var dup = vt_options; + dup.c_abi = true; + break :options dup; + }, + ), + }; +} + +fn initVt( + name: []const u8, + b: *std.Build, + cfg: *const Config, + deps: *const SharedDeps, + vt_options: TerminalBuildOptions, +) !*std.Build.Module { + // General build options + const general_options = b.addOptions(); + try cfg.addOptions(general_options); + + const vt = b.addModule(name, .{ .root_source_file = b.path("src/lib_vt.zig"), .target = cfg.target, .optimize = cfg.optimize, @@ -45,5 +78,5 @@ pub fn init( try SharedDeps.addSimd(b, vt, null); } - return .{ .vt = vt }; + return vt; } diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 37ab7ae68..b7ef9459a 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -7,6 +7,7 @@ //! by thousands of users for years. However, the API itself (functions, //! types, etc.) may change without warning. We're working on stabilizing //! this in the future. +const lib = @This(); // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` @@ -68,7 +69,7 @@ pub const Attribute = terminal.Attribute; comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. - if (terminal.is_c_lib) { + if (@import("root") == lib) { const c = terminal.c_api; @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @@ -81,8 +82,8 @@ comptime { test { _ = terminal; - - // Tests always test the C API and shared C functions - _ = terminal.c_api; _ = @import("lib/main.zig"); + if (comptime terminal.options.c_abi) { + _ = terminal.c_api; + } } diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 2085e2243..e209a56fa 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -1,8 +1,6 @@ const std = @import("std"); -/// True if we're building the C library libghostty-vt. -pub const is_c_lib = @import("root") == @import("../lib_vt.zig"); - +/// Options set by Zig build.zig and exposed via `terminal_options`. pub const Options = struct { /// The target artifact to build. This will gate some functionality. artifact: Artifact, @@ -26,6 +24,10 @@ pub const Options = struct { /// generally be disabled in production builds. slow_runtime_safety: bool, + /// Force C ABI mode on or off. If not set, then it will be set based on + /// Options. + c_abi: bool, + /// Add the required build options for the terminal module. pub fn add( self: Options, @@ -34,6 +36,7 @@ pub const Options = struct { ) void { const opts = b.addOptions(); opts.addOption(Artifact, "artifact", self.artifact); + opts.addOption(bool, "c_abi", self.c_abi); opts.addOption(bool, "oniguruma", self.oniguruma); opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index c04626b69..d1998f4e1 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -73,9 +73,9 @@ test "command type" { )); defer free(p); - p.next('0'); - p.next(';'); - p.next('a'); - const cmd = p.end(0); + next(p, '0'); + next(p, ';'); + next(p, 'a'); + const cmd = end(p, 0); try testing.expectEqual(.change_window_title, commandType(cmd)); } diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 7403ff309..6875ba89d 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -1,5 +1,4 @@ const builtin = @import("builtin"); -const build_options = @import("terminal_options"); const charsets = @import("charsets.zig"); const sanitize = @import("sanitize.zig"); @@ -21,7 +20,7 @@ pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const search = @import("search.zig"); pub const size = @import("size.zig"); -pub const tmux = if (build_options.tmux_control_mode) @import("tmux.zig") else struct {}; +pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; @@ -62,9 +61,11 @@ pub const Attribute = sgr.Attribute; pub const isSafePaste = sanitize.isSafePaste; +pub const Options = @import("build_options.zig").Options; +pub const options = @import("terminal_options"); + /// This is set to true when we're building the C library. -pub const is_c_lib = @import("build_options.zig").is_c_lib; -pub const c_api = if (is_c_lib) @import("c/main.zig") else void; +pub const c_api = if (options.c_abi) @import("c/main.zig") else void; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 71d2f8598..20b22d1ef 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -7,11 +7,11 @@ const osc = @This(); const std = @import("std"); const builtin = @import("builtin"); +const build_options = @import("terminal_options"); const mem = std.mem; const assert = std.debug.assert; const Allocator = mem.Allocator; const LibEnum = @import("../lib/enum.zig").Enum; -const is_c_lib = @import("build_options.zig").is_c_lib; const RGB = @import("color.zig").RGB; const kitty_color = @import("kitty/color.zig"); const osc_color = @import("osc/color.zig"); @@ -175,7 +175,7 @@ pub const Command = union(Key) { conemu_guimacro: []const u8, pub const Key = LibEnum( - if (is_c_lib) .c else .zig, + if (build_options.c_abi) .c else .zig, // NOTE: Order matters, see LibEnum documentation. &.{ "invalid", From 41c1c6b3e633ae63a8499eaaf2351ba631afe64c Mon Sep 17 00:00:00 2001 From: Bernd Kaiser Date: Mon, 29 Sep 2025 14:44:20 +0200 Subject: [PATCH 38/52] gtk: make Enter confirm "Change Terminal Title" --- src/apprt/gtk/ui/1.5/surface-title-dialog.blp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/ui/1.5/surface-title-dialog.blp b/src/apprt/gtk/ui/1.5/surface-title-dialog.blp index 24ae26f37..90d9f9c0b 100644 --- a/src/apprt/gtk/ui/1.5/surface-title-dialog.blp +++ b/src/apprt/gtk/ui/1.5/surface-title-dialog.blp @@ -6,11 +6,14 @@ template $GhosttySurfaceTitleDialog: Adw.AlertDialog { body: _("Leave blank to restore the default title."); responses [ - cancel: _("Cancel") suggested, - ok: _("OK") destructive, + cancel: _("Cancel"), + ok: _("OK") suggested, ] + default-response: "ok"; focus-widget: entry; - extra-child: Entry entry {}; + extra-child: Entry entry { + activates-default: true; + }; } From 3bc07c24aaac4cf58cbb845bc54c8c1cbf2ffa0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 28 Sep 2025 14:55:15 -0700 Subject: [PATCH 39/52] lib-vt: OSC data extraction boilerplate This also changes OSC strings to be null-terminated to ease lib-vt integration. This shouldn't have any practical effect on terminal performance, but it does lower the maximum length of OSC strings by 1 since we always reserve space for the null terminator. --- example/c-vt/src/main.c | 15 +++++- include/ghostty/vt.h | 69 ++++++++++++++++++++++++++++ src/inspector/termio.zig | 8 +++- src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/osc.zig | 53 +++++++++++++++++++++- src/terminal/osc.zig | 98 ++++++++++++++++++++++++++++++---------- 7 files changed, 216 insertions(+), 29 deletions(-) diff --git a/example/c-vt/src/main.c b/example/c-vt/src/main.c index 00ea3618f..b1297d7a7 100644 --- a/example/c-vt/src/main.c +++ b/example/c-vt/src/main.c @@ -1,5 +1,6 @@ #include #include +#include #include int main() { @@ -8,10 +9,13 @@ int main() { return 1; } - // Setup change window title command to change the title to "a" + // Setup change window title command to change the title to "hello" ghostty_osc_next(parser, '0'); ghostty_osc_next(parser, ';'); - ghostty_osc_next(parser, 'a'); + const char *title = "hello"; + for (size_t i = 0; i < strlen(title); i++) { + ghostty_osc_next(parser, title[i]); + } // End parsing and get command GhosttyOscCommand command = ghostty_osc_end(parser, 0); @@ -20,6 +24,13 @@ int main() { GhosttyOscCommandType type = ghostty_osc_command_type(command); printf("Command type: %d\n", type); + // Extract and print the title + if (ghostty_osc_command_data(command, GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR, &title)) { + printf("Extracted title: %s\n", title); + } else { + printf("Failed to extract title\n"); + } + ghostty_osc_free(parser); return 0; } diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 5d80cb653..33ff2a961 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -32,6 +32,8 @@ extern "C" { * be used to parse the contents of OSC sequences. This isn't a full VT * parser; it is only the OSC parser component. This is useful if you have * a parser already and want to only extract and handle OSC sequences. + * + * @ingroup osc */ typedef struct GhosttyOscParser *GhosttyOscParser; @@ -41,6 +43,8 @@ typedef struct GhosttyOscParser *GhosttyOscParser; * This handle represents a parsed OSC (Operating System Command) command. * The command can be queried for its type and associated data using * `ghostty_osc_command_type` and `ghostty_osc_command_data`. + * + * @ingroup osc */ typedef struct GhosttyOscCommand *GhosttyOscCommand; @@ -56,6 +60,8 @@ typedef enum { /** * OSC command types. + * + * @ingroup osc */ typedef enum { GHOSTTY_OSC_COMMAND_INVALID = 0, @@ -81,6 +87,31 @@ typedef enum { GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20, } GhosttyOscCommandType; +/** + * OSC command data types. + * + * These values specify what type of data to extract from an OSC command + * using `ghostty_osc_command_data`. + * + * @ingroup osc + */ +typedef enum { + /** Invalid data type. Never results in any data extraction. */ + GHOSTTY_OSC_DATA_INVALID = 0, + + /** + * Window title string data. + * + * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE + * + * Output type: const char ** (pointer to null-terminated string) + * + * Lifetime: Valid until the next call to any ghostty_osc_* function with + * the same parser instance. Memory is owned by the parser. + */ + GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1, +} GhosttyOscCommandData; + //------------------------------------------------------------------- // Allocator Interface @@ -227,6 +258,27 @@ typedef struct { //------------------------------------------------------------------- // Functions +/** @defgroup osc OSC Parser + * + * OSC (Operating System Command) sequence parser and command handling. + * + * The parser operates in a streaming fashion, processing input byte-by-byte + * to handle OSC sequences that may arrive in fragments across multiple reads. + * This interface makes it easy to integrate into most environments and avoids + * over-allocating buffers. + * + * ## Basic Usage + * + * 1. Create a parser instance with ghostty_osc_new() + * 2. Feed bytes to the parser using ghostty_osc_next() + * 3. Finalize parsing with ghostty_osc_end() to get the command + * 4. Query command type and extract data using ghostty_osc_command_type() + * and ghostty_osc_command_data() + * 5. Free the parser with ghostty_osc_free() when done + * + * @{ + */ + /** * Create a new OSC parser instance. * @@ -316,6 +368,23 @@ GhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator); */ GhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command); +/** + * Extract data from an OSC command. + * + * Extracts typed data from the given OSC command based on the specified + * data type. The output pointer must be of the appropriate type for the + * requested data kind. Valid command types, output types, and memory + * safety information are documented in the `GhosttyOscCommandData` enum. + * + * @param command The OSC command handle to query (may be NULL) + * @param data The type of data to extract + * @param out Pointer to store the extracted data (type depends on data parameter) + * @return true if data extraction was successful, false otherwise + */ +bool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out); + +/** @} */ // end of osc group + #ifdef __cplusplus } #endif diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig index 5ab9d3cd4..49ab00ecd 100644 --- a/src/inspector/termio.zig +++ b/src/inspector/termio.zig @@ -197,7 +197,9 @@ pub const VTEvent = struct { ) !void { switch (@TypeOf(v)) { void => {}, - []const u8 => try md.put("data", try alloc.dupeZ(u8, v)), + []const u8, + [:0]const u8, + => try md.put("data", try alloc.dupeZ(u8, v)), else => |T| switch (@typeInfo(T)) { .@"struct" => |info| inline for (info.fields) |field| { try encodeMetadataSingle( @@ -284,7 +286,9 @@ pub const VTEvent = struct { try std.fmt.allocPrintZ(alloc, "{}", .{value}), ), - []const u8 => try md.put(key, try alloc.dupeZ(u8, value)), + []const u8, + [:0]const u8, + => try md.put(key, try alloc.dupeZ(u8, value)), else => |T| { @compileLog(T); diff --git a/src/lib_vt.zig b/src/lib_vt.zig index b7ef9459a..763f17f98 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -77,6 +77,7 @@ comptime { @export(&c.osc_reset, .{ .name = "ghostty_osc_reset" }); @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); + @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); } } diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index f32dd226f..68fd77edd 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -7,6 +7,7 @@ pub const osc_reset = osc.reset; pub const osc_next = osc.next; pub const osc_end = osc.end; pub const osc_command_type = osc.commandType; +pub const osc_command_data = osc.commandData; test { _ = osc; diff --git a/src/terminal/c/osc.zig b/src/terminal/c/osc.zig index d1998f4e1..8b6a8409c 100644 --- a/src/terminal/c/osc.zig +++ b/src/terminal/c/osc.zig @@ -49,6 +49,51 @@ pub fn commandType(command_: Command) callconv(.c) osc.Command.Key { return command.*; } +/// C: GhosttyOscCommandData +pub const CommandData = enum(c_int) { + invalid = 0, + change_window_title_str = 1, + + /// Output type expected for querying the data of the given kind. + pub fn OutType(comptime self: CommandData) type { + return switch (self) { + .invalid => void, + .change_window_title_str => [*:0]const u8, + }; + } +}; + +pub fn commandData( + command_: Command, + data: CommandData, + out: ?*anyopaque, +) callconv(.c) bool { + return switch (data) { + inline else => |comptime_data| commandDataTyped( + command_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn commandDataTyped( + command_: Command, + comptime data: CommandData, + out: *data.OutType(), +) bool { + const command = command_.?; + switch (data) { + .invalid => return false, + .change_window_title_str => switch (command.*) { + .change_window_title => |v| out.* = v.ptr, + else => return false, + }, + } + + return true; +} + test "alloc" { const testing = std.testing; var p: Parser = undefined; @@ -64,7 +109,7 @@ test "command type null" { try testing.expectEqual(.invalid, commandType(null)); } -test "command type" { +test "change window title" { const testing = std.testing; var p: Parser = undefined; try testing.expectEqual(Result.success, new( @@ -73,9 +118,15 @@ test "command type" { )); defer free(p); + // Parse it next(p, '0'); next(p, ';'); next(p, 'a'); const cmd = end(p, 0); try testing.expectEqual(.change_window_title, commandType(cmd)); + + // Extract the title + var title: [*:0]const u8 = undefined; + try testing.expect(commandData(cmd, .change_window_title_str, @ptrCast(&title))); + try testing.expectEqualStrings("a", std.mem.span(title)); } diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 20b22d1ef..800257c3d 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -26,19 +26,19 @@ pub const Command = union(Key) { /// Set the window title of the terminal /// - /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 + /// If title mode 0 is set text is expect to be hex encoded (i.e. utf-8 /// with each code unit further encoded with two hex digits). /// /// If title mode 2 is set or the terminal is setup for unconditional /// utf-8 titles text is interpreted as utf-8. Else text is interpreted /// as latin1. - change_window_title: []const u8, + change_window_title: [:0]const u8, /// Set the icon of the terminal window. The name of the icon is not /// well defined, so this is currently ignored by Ghostty at the time /// of writing this. We just parse it so that we don't get parse errors /// in the log. - change_window_icon: []const u8, + change_window_icon: [:0]const u8, /// First do a fresh-line. Then start a new command, and enter prompt mode: /// Subsequent text (until a OSC "133;B" or OSC "133;I" command) is a @@ -54,7 +54,7 @@ pub const Command = union(Key) { /// - secondary: a non-editable continuation line /// - right: a right-aligned prompt that may need adjustment during reflow prompt_start: struct { - aid: ?[]const u8 = null, + aid: ?[:0]const u8 = null, kind: enum { primary, continuation, secondary, right } = .primary, redraw: bool = true, }, @@ -96,7 +96,7 @@ pub const Command = union(Key) { /// contents is set on the clipboard. clipboard_contents: struct { kind: u8, - data: []const u8, + data: [:0]const u8, }, /// OSC 7. Reports the current working directory of the shell. This is @@ -106,7 +106,7 @@ pub const Command = union(Key) { report_pwd: struct { /// The reported pwd value. This is not checked for validity. It should /// be a file URL but it is up to the caller to utilize this value. - value: []const u8, + value: [:0]const u8, }, /// OSC 22. Set the mouse shape. There doesn't seem to be a standard @@ -114,7 +114,7 @@ pub const Command = union(Key) { /// are moving towards using the W3C CSS cursor names. For OSC parsing, /// we just parse whatever string is given. mouse_shape: struct { - value: []const u8, + value: [:0]const u8, }, /// OSC color operations to set, reset, or report color settings. Some OSCs @@ -138,14 +138,14 @@ pub const Command = union(Key) { /// Show a desktop notification (OSC 9 or OSC 777) show_desktop_notification: struct { - title: []const u8, - body: []const u8, + title: [:0]const u8, + body: [:0]const u8, }, /// Start a hyperlink (OSC 8) hyperlink_start: struct { - id: ?[]const u8 = null, - uri: []const u8, + id: ?[:0]const u8 = null, + uri: [:0]const u8, }, /// End a hyperlink (OSC 8) @@ -157,12 +157,12 @@ pub const Command = union(Key) { }, /// ConEmu show GUI message box (OSC 9;2) - conemu_show_message_box: []const u8, + conemu_show_message_box: [:0]const u8, /// ConEmu change tab title (OSC 9;3) conemu_change_tab_title: union(enum) { reset, - value: []const u8, + value: [:0]const u8, }, /// ConEmu progress report (OSC 9;4) @@ -172,7 +172,7 @@ pub const Command = union(Key) { conemu_wait_input, /// ConEmu GUI macro (OSC 9;6) - conemu_guimacro: []const u8, + conemu_guimacro: [:0]const u8, pub const Key = LibEnum( if (build_options.c_abi) .c else .zig, @@ -305,7 +305,7 @@ pub const Parser = struct { /// Temporary state that is dependent on the current state. temp_state: union { /// Current string parameter being populated - str: *[]const u8, + str: *[:0]const u8, /// Current numeric parameter being populated num: u16, @@ -498,7 +498,10 @@ pub const Parser = struct { // If our buffer is full then we're invalid, so we set our state // accordingly and indicate the sequence is incomplete so that we // don't accidentally issue a command when ending. - if (self.buf_idx >= self.buf.len) { + // + // We always keep space for 1 byte at the end to null-terminate + // values. + if (self.buf_idx >= self.buf.len - 1) { if (self.state != .invalid) { log.warn( "OSC sequence too long (> {d}), ignoring. state={}", @@ -1037,7 +1040,8 @@ pub const Parser = struct { .notification_title => switch (c) { ';' => { - self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.buf[self.buf_idx - 1] = 0; + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1 :0]; self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; self.buf_start = self.buf_idx; self.state = .string; @@ -1406,7 +1410,8 @@ pub const Parser = struct { fn endHyperlink(self: *Parser) void { switch (self.command) { .hyperlink_start => |*v| { - const value = self.buf[self.buf_start..self.buf_idx]; + self.buf[self.buf_idx] = 0; + const value = self.buf[self.buf_start..self.buf_idx :0]; if (v.id == null and value.len == 0) { self.command = .{ .hyperlink_end = {} }; return; @@ -1420,10 +1425,12 @@ pub const Parser = struct { } fn endHyperlinkOptionValue(self: *Parser) void { - const value = if (self.buf_start == self.buf_idx) + const value: [:0]const u8 = if (self.buf_start == self.buf_idx) "" - else - self.buf[self.buf_start .. self.buf_idx - 1]; + else buf: { + self.buf[self.buf_idx - 1] = 0; + break :buf self.buf[self.buf_start .. self.buf_idx - 1 :0]; + }; if (mem.eql(u8, self.temp_state.key, "id")) { switch (self.command) { @@ -1438,7 +1445,11 @@ pub const Parser = struct { } fn endSemanticOptionValue(self: *Parser) void { - const value = self.buf[self.buf_start..self.buf_idx]; + const value = value: { + self.buf[self.buf_idx] = 0; + defer self.buf_idx += 1; + break :value self.buf[self.buf_start..self.buf_idx :0]; + }; if (mem.eql(u8, self.temp_state.key, "aid")) { switch (self.command) { @@ -1495,7 +1506,9 @@ pub const Parser = struct { } fn endString(self: *Parser) void { - self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx]; + self.buf[self.buf_idx] = 0; + defer self.buf_idx += 1; + self.temp_state.str.* = self.buf[self.buf_start..self.buf_idx :0]; } fn endConEmuSleepValue(self: *Parser) void { @@ -1589,8 +1602,15 @@ pub const Parser = struct { } fn endAllocableString(self: *Parser) void { + const alloc = self.alloc.?; const list = self.buf_dynamic.?; - self.temp_state.str.* = list.items; + list.append(alloc, 0) catch { + log.warn("allocation failed on allocable string termination", .{}); + self.temp_state.str.* = ""; + return; + }; + + self.temp_state.str.* = list.items[0 .. list.items.len - 1 :0]; } /// End the sequence and return the command, if any. If the return value @@ -1976,6 +1996,36 @@ test "OSC: longer than buffer" { try testing.expect(p.complete == false); } +test "OSC: one shorter than buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len - 1); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .change_window_title); + try testing.expectEqualStrings(title, cmd.change_window_title); +} + +test "OSC: exactly at buffer length" { + const testing = std.testing; + + var p: Parser = .init(); + + const prefix = "0;"; + const title = "a" ** (Parser.MAX_BUF - prefix.len); + const input = prefix ++ title; + for (input) |ch| p.next(ch); + + // This should be null because we always reserve space for a null terminator. + try testing.expect(p.end(null) == null); + try testing.expect(p.complete == false); +} + test "OSC: OSC 9;1 ConEmu sleep" { const testing = std.testing; From 3a95920edf01dbec33d6a0ab607b5f47b586ea51 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 08:31:27 -0700 Subject: [PATCH 40/52] build: add Dockerfile to generate and server libghostty c docs --- src/build/docker/lib-c-docs/Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/build/docker/lib-c-docs/Dockerfile diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile new file mode 100644 index 000000000..ce9f3e4c0 --- /dev/null +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -0,0 +1,21 @@ +#-------------------------------------------------------------------- +# Generate documentation with Doxygen +#-------------------------------------------------------------------- +FROM ubuntu:24.04 AS builder +RUN apt-get update && apt-get install -y \ + doxygen \ + graphviz \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /ghostty +COPY include/ ./include/ +COPY Doxyfile ./ +RUN mkdir -p zig-out/share/ghostty/doc/libghostty +RUN doxygen + +#-------------------------------------------------------------------- +# Host the static HTML +#-------------------------------------------------------------------- +FROM nginx:alpine AS runtime +COPY --from=builder /ghostty/zig-out/share/ghostty/doc/libghostty /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] From 904e09a1daa0caa04f05e7cf0de733cc7b0661f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 08:46:05 -0700 Subject: [PATCH 41/52] build: add NOINDEX argument for libghostty-vt docs --- src/build/docker/lib-c-docs/Dockerfile | 35 +++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index ce9f3e4c0..782e99994 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -2,6 +2,9 @@ # Generate documentation with Doxygen #-------------------------------------------------------------------- FROM ubuntu:24.04 AS builder + +# Build argument for noindex header +ARG ADD_NOINDEX_HEADER=false RUN apt-get update && apt-get install -y \ doxygen \ graphviz \ @@ -16,6 +19,36 @@ RUN doxygen # Host the static HTML #-------------------------------------------------------------------- FROM nginx:alpine AS runtime + +# Pass build arg to runtime stage +ARG ADD_NOINDEX_HEADER=false +ENV ADD_NOINDEX_HEADER=$ADD_NOINDEX_HEADER + +# Copy documentation COPY --from=builder /ghostty/zig-out/share/ghostty/doc/libghostty /usr/share/nginx/html + +# Create entrypoint script +RUN cat > /entrypoint.sh << 'EOF' +#!/bin/sh +if [ "$ADD_NOINDEX_HEADER" = "true" ]; then + cat > /etc/nginx/conf.d/noindex.conf << 'INNER_EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + add_header X-Robots-Tag "noindex, nofollow" always; + } +} +INNER_EOF + + # Remove default server config + rm -f /etc/nginx/conf.d/default.conf +fi +exec nginx -g "daemon off;" +EOF + +RUN chmod +x /entrypoint.sh + EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] +CMD ["/entrypoint.sh"] From 16077f054296080086731e6bd523e6d993322763 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 08:51:55 -0700 Subject: [PATCH 42/52] build: move entrypoint out to separate file --- src/build/docker/lib-c-docs/Dockerfile | 25 ++--------------------- src/build/docker/lib-c-docs/entrypoint.sh | 16 +++++++++++++++ 2 files changed, 18 insertions(+), 23 deletions(-) create mode 100755 src/build/docker/lib-c-docs/entrypoint.sh diff --git a/src/build/docker/lib-c-docs/Dockerfile b/src/build/docker/lib-c-docs/Dockerfile index 782e99994..f30dfba90 100644 --- a/src/build/docker/lib-c-docs/Dockerfile +++ b/src/build/docker/lib-c-docs/Dockerfile @@ -24,30 +24,9 @@ FROM nginx:alpine AS runtime ARG ADD_NOINDEX_HEADER=false ENV ADD_NOINDEX_HEADER=$ADD_NOINDEX_HEADER -# Copy documentation +# Copy documentation and entrypoint script COPY --from=builder /ghostty/zig-out/share/ghostty/doc/libghostty /usr/share/nginx/html - -# Create entrypoint script -RUN cat > /entrypoint.sh << 'EOF' -#!/bin/sh -if [ "$ADD_NOINDEX_HEADER" = "true" ]; then - cat > /etc/nginx/conf.d/noindex.conf << 'INNER_EOF' -server { - listen 80; - location / { - root /usr/share/nginx/html; - index index.html; - add_header X-Robots-Tag "noindex, nofollow" always; - } -} -INNER_EOF - - # Remove default server config - rm -f /etc/nginx/conf.d/default.conf -fi -exec nginx -g "daemon off;" -EOF - +COPY src/build/docker/lib-c-docs/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh EXPOSE 80 diff --git a/src/build/docker/lib-c-docs/entrypoint.sh b/src/build/docker/lib-c-docs/entrypoint.sh new file mode 100755 index 000000000..928d6e163 --- /dev/null +++ b/src/build/docker/lib-c-docs/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +if [ "$ADD_NOINDEX_HEADER" = "true" ]; then + cat > /etc/nginx/conf.d/noindex.conf << 'EOF' +server { + listen 80; + location / { + root /usr/share/nginx/html; + index index.html; + add_header X-Robots-Tag "noindex, nofollow" always; + } +} +EOF + # Remove default server config + rm -f /etc/nginx/conf.d/default.conf +fi +exec nginx -g "daemon off;" From 15670a77f3010961f94303fcb8f94e87605cdba2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 08:59:16 -0700 Subject: [PATCH 43/52] lib-vt: document main html page --- include/ghostty/vt.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 33ff2a961..a33d2c9ee 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -11,6 +11,26 @@ * stable and is definitely going to change. */ +/** + * @mainpage libghostty-vt - Virtual Terminal Sequence Parser + * + * libghostty-vt is a C library which implements a modern terminal emulator, + * extracted from the [Ghostty](https://ghostty.org) terminal emulator. + * + * libghostty-vt contains the logic for handling the core parts of a terminal + * emulator: parsing terminal escape sequences and maintaining terminal state. + * It can handle scrollback, line wrapping, reflow on resize, and more. + * + * @warning This library is currently in development and the API is not yet stable. + * Breaking changes are expected in future versions. Use with caution in production code. + * + * @section groups_sec API Reference + * + * The API is organized into the following groups: + * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H From cc0b7f74fdbbc56ae0005c0a76ee21731355d9e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 09:12:09 -0700 Subject: [PATCH 44/52] lib-vt: document allocators --- include/ghostty/vt.h | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index a33d2c9ee..4b930a96f 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -28,6 +28,7 @@ * * The API is organized into the following groups: * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences + * - @ref allocator "Memory Management" - Memory management and custom allocators * */ @@ -135,12 +136,50 @@ typedef enum { //------------------------------------------------------------------- // Allocator Interface +/** @defgroup allocator Memory Management + * + * libghostty-vt does require memory allocation for various operations, + * but is resilient to allocation failures and will gracefully handle + * out-of-memory situations by returning error codes. + * + * The exact memory management semantics are documented in the relevant + * functions and data structures. + * + * libghostty-vt uses explicit memory allocation via an allocator + * interface provided by GhosttyAllocator. The interface is based on the + * [Zig](https://ziglang.org) allocator interface, since this has been + * shown to be a flexible and powerful interface in practice and enables + * a wide variety of allocation strategies. + * + * **For the common case, you can pass NULL as the allocator for any + * function that accepts one,** and libghostty will use a default allocator. + * The default allocator will be libc malloc/free if libc is linked. + * Otherwise, a custom allocator is used (currently Zig's SMP allocator) + * that doesn't require any external dependencies. + * + * ## Basic Usage + * + * For simple use cases, you can ignore this interface entirely by passing NULL + * as the allocator parameter to functions that accept one. This will use the + * default allocator (typically libc malloc/free, if libc is linked, but + * we provide our own default allocator if libc isn't linked). + * + * To use a custom allocator: + * 1. Implement the GhosttyAllocatorVtable function pointers + * 2. Create a GhosttyAllocator struct with your vtable and context + * 3. Pass the allocator to functions that accept one + * + * @{ + */ + /** * Function table for custom memory allocator operations. * * This vtable defines the interface for a custom memory allocator. All * function pointers must be valid and non-NULL. * + * @ingroup allocator + * * If you're not going to use a custom allocator, you can ignore all of * this. All functions that take an allocator pointer allow NULL to use a * default allocator. @@ -252,6 +291,8 @@ typedef struct { * be libc malloc/free if we're linking to libc. If libc isn't linked, * a custom allocator is used (currently Zig's SMP allocator). * + * @ingroup allocator + * * Usage example: * @code * GhosttyAllocator allocator = { @@ -275,6 +316,8 @@ typedef struct { const GhosttyAllocatorVtable *vtable; } GhosttyAllocator; +/** @} */ // end of allocator group + //------------------------------------------------------------------- // Functions From 9ba45b21639870af1f675969a67fca4a52ab9fa6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 09:18:41 -0700 Subject: [PATCH 45/52] lib-vt: fix invalid Zig forwards --- src/lib_vt.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 763f17f98..8c49b4900 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -31,8 +31,8 @@ pub const size = terminal.size; pub const x11_color = terminal.x11_color; pub const Charset = terminal.Charset; -pub const CharsetSlot = terminal.Slots; -pub const CharsetActiveSlot = terminal.ActiveSlot; +pub const CharsetSlot = terminal.CharsetSlot; +pub const CharsetActiveSlot = terminal.CharsetActiveSlot; pub const Cell = page.Cell; pub const Coordinate = point.Coordinate; pub const CSI = Parser.Action.CSI; From 33e0701965303d880365529ff71e816b29bfd33d Mon Sep 17 00:00:00 2001 From: Toufiq Shishir Date: Fri, 26 Sep 2025 23:35:49 +0600 Subject: [PATCH 46/52] feat: enable separate scaling for precision and discrete mouse scrolling --- src/Surface.zig | 6 +-- src/config.zig | 1 + src/config/Config.zig | 121 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 8edeadf83..03974dfc6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -260,7 +260,7 @@ const DerivedConfig = struct { font: font.SharedGridSet.DerivedConfig, mouse_interval: u64, mouse_hide_while_typing: bool, - mouse_scroll_multiplier: f64, + mouse_scroll_multiplier: configpkg.MouseScrollMultiplier, mouse_shift_capture: configpkg.MouseShiftCapture, macos_non_native_fullscreen: configpkg.NonNativeFullscreen, macos_option_as_alt: ?configpkg.OptionAsAlt, @@ -2829,7 +2829,7 @@ pub fn scrollCallback( // scroll events to pixels by multiplying the wheel tick value and the cell size. This means // that a wheel tick of 1 results in single scroll event. const yoff_adjusted: f64 = if (scroll_mods.precision) - yoff + yoff * self.config.mouse_scroll_multiplier.precision else yoff_adjusted: { // Round out the yoff to an absolute minimum of 1. macos tries to // simulate precision scrolling with non precision events by @@ -2843,7 +2843,7 @@ pub fn scrollCallback( else @min(yoff, -1); - break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier; + break :yoff_adjusted yoff_max * cell_size * self.config.mouse_scroll_multiplier.discrete; }; // Add our previously saved pending amount to the offset to get the diff --git a/src/config.zig b/src/config.zig index e83dff530..569d4bec2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -27,6 +27,7 @@ pub const FontStyle = Config.FontStyle; pub const FreetypeLoadFlags = Config.FreetypeLoadFlags; pub const Keybinds = Config.Keybinds; pub const MouseShiftCapture = Config.MouseShiftCapture; +pub const MouseScrollMultiplier = Config.MouseScrollMultiplier; pub const NonNativeFullscreen = Config.NonNativeFullscreen; pub const OptionAsAlt = Config.OptionAsAlt; pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; diff --git a/src/config/Config.zig b/src/config/Config.zig index 66e63fd3f..27966fee0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -834,13 +834,14 @@ palette: Palette = .{}, @"mouse-shift-capture": MouseShiftCapture = .false, /// Multiplier for scrolling distance with the mouse wheel. Any value less -/// than 0.01 or greater than 10,000 will be clamped to the nearest valid -/// value. +/// than 0.01 (0.01 for precision scroll) or greater than 10,000 will be clamped +/// to the nearest valid value. /// -/// A value of "3" (default) scrolls 3 lines per tick. +/// A discrete value of "3" (default) scrolls about 3 lines per wheel tick. +/// And a precision value of "0.1" (default) scales pixel-level scrolling. /// -/// Available since: 1.2.0 -@"mouse-scroll-multiplier": f64 = 3.0, +/// Available since: 1.2.1 +@"mouse-scroll-multiplier": MouseScrollMultiplier = .{ .precision = 0.1, .discrete = 3.0 }, /// The opacity level (opposite of transparency) of the background. A value of /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 @@ -4077,7 +4078,8 @@ pub fn finalize(self: *Config) !void { } // Clamp our mouse scroll multiplier - self.@"mouse-scroll-multiplier" = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier")); + self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.1, self.@"mouse-scroll-multiplier".precision)); + self.@"mouse-scroll-multiplier".discrete = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".discrete)); // Clamp our split opacity self.@"unfocused-split-opacity" = @min(1.0, @max(0.15, self.@"unfocused-split-opacity")); @@ -6508,7 +6510,7 @@ pub const RepeatableCodepointMap = struct { return .{ .map = try self.map.clone(alloc) }; } - /// Compare if two of our value are requal. Required by Config. + /// Compare if two of our value are equal. Required by Config. pub fn equal(self: Self, other: Self) bool { const itemsA = self.map.list.slice(); const itemsB = other.map.list.slice(); @@ -7319,6 +7321,111 @@ pub const MouseShiftCapture = enum { never, }; +/// See mouse-scroll-multiplier +pub const MouseScrollMultiplier = struct { + const Self = @This(); + + precision: f64, + discrete: f64, + + pub fn parseCLI(self: *Self, input_: ?[]const u8) !void { + const input_raw = input_ orelse return error.ValueRequired; + const whitespace = " \t"; + const input = std.mem.trim(u8, input_raw, whitespace); + if (input.len == 0) return error.ValueRequired; + + const value = std.fmt.parseFloat(f64, input) catch null; + if (value) |val| { + self.precision = val; + self.discrete = val; + return; + } + + const comma_idx = std.mem.indexOf(u8, input, ","); + if (comma_idx) |idx| { + if (std.mem.indexOf(u8, input[idx + 1 ..], ",")) |_| return error.InvalidValue; + + const lhs = std.mem.trim(u8, input[0..idx], whitespace); + const rhs = std.mem.trim(u8, input[idx + 1 ..], whitespace); + if (lhs.len == 0 or rhs.len == 0) return error.InvalidValue; + + const lcolon_idx = std.mem.indexOf(u8, lhs, ":") orelse return error.InvalidValue; + const rcolon_idx = std.mem.indexOf(u8, rhs, ":") orelse return error.InvalidValue; + const lkey = lhs[0..lcolon_idx]; + const lvalstr = std.mem.trim(u8, lhs[lcolon_idx + 1 ..], whitespace); + const rkey = rhs[0..rcolon_idx]; + const rvalstr = std.mem.trim(u8, rhs[rcolon_idx + 1 ..], whitespace); + + // Only "precision" and "discrete" are valid keys. They + // must be different. + if (std.mem.eql(u8, lkey, rkey)) return error.InvalidValue; + + var found_precision = false; + var found_discrete = false; + var precision_val = self.precision; + var discrete_val = self.discrete; + + if (std.mem.eql(u8, lkey, "precision")) { + precision_val = std.fmt.parseFloat(f64, lvalstr) catch return error.InvalidValue; + found_precision = true; + } else if (std.mem.eql(u8, lkey, "discrete")) { + discrete_val = std.fmt.parseFloat(f64, lvalstr) catch return error.InvalidValue; + found_discrete = true; + } else return error.InvalidValue; + + if (std.mem.eql(u8, rkey, "precision")) { + precision_val = std.fmt.parseFloat(f64, rvalstr) catch return error.InvalidValue; + found_precision = true; + } else if (std.mem.eql(u8, rkey, "discrete")) { + discrete_val = std.fmt.parseFloat(f64, rvalstr) catch return error.InvalidValue; + found_discrete = true; + } else return error.InvalidValue; + + if (!found_precision or !found_discrete) return error.InvalidValue; + if (precision_val == 0 or discrete_val == 0) return error.InvalidValue; + + self.precision = precision_val; + self.discrete = discrete_val; + + return; + } else { + const colon_idx = std.mem.indexOf(u8, input, ":") orelse return error.InvalidValue; + const key = input[0..colon_idx]; + const valstr = std.mem.trim(u8, input[colon_idx + 1 ..], whitespace); + if (valstr.len == 0) return error.InvalidValue; + + const val = std.fmt.parseFloat(f64, valstr) catch return error.InvalidValue; + if (val == 0) return error.InvalidValue; + + if (std.mem.eql(u8, key, "precision")) { + self.precision = val; + return; + } else if (std.mem.eql(u8, key, "discrete")) { + self.discrete = val; + return; + } else return error.InvalidValue; + } + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self { + _ = alloc; + return self.*; + } + + /// Compare if two of our value are equal. Required by Config. + pub fn equal(self: Self, other: Self) bool { + return self.precision == other.precision and self.discrete == other.discrete; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: anytype) !void { + var buf: [32]u8 = undefined; + const formatted = try std.fmt.bufPrint(&buf, "precision:{d},discrete:{d}", .{ self.precision, self.discrete }); + try formatter.formatEntry([]const u8, formatted); + } +}; + /// How to treat requests to write to or read from the clipboard pub const ClipboardAccess = enum { allow, From 9597cead92eaf053ff38113a54882a016bc0c40b Mon Sep 17 00:00:00 2001 From: Toufiq Shishir Date: Sat, 27 Sep 2025 00:52:56 +0600 Subject: [PATCH 47/52] add: unit tests for MouseScrollMultiplier parsing and formatting --- src/config/Config.zig | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 27966fee0..63db07235 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -7424,6 +7424,53 @@ pub const MouseScrollMultiplier = struct { const formatted = try std.fmt.bufPrint(&buf, "precision:{d},discrete:{d}", .{ self.precision, self.discrete }); try formatter.formatEntry([]const u8, formatted); } + + test "parse MouseScrollMultiplier" { + const testing = std.testing; + + var args: Self = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI("3"); + try testing.expect(args.precision == 3 and args.discrete == 3); + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI("precision:1"); + try testing.expect(args.precision == 1 and args.discrete == 3); + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI("discrete:5"); + try testing.expect(args.precision == 0.1 and args.discrete == 5); + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI("precision:3,discrete:7"); + try testing.expect(args.precision == 3 and args.discrete == 7); + args = .{ .precision = 0.1, .discrete = 3 }; + try args.parseCLI("discrete:8,precision:6"); + try testing.expect(args.precision == 6 and args.discrete == 8); + + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("foo:1")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("precision:bar")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,precision:3")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.ValueRequired, args.parseCLI("")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,discrete:3,foo:5")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,,discrete:3")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,discrete:3,")); + args = .{ .precision = 0.1, .discrete = 3 }; + try testing.expectError(error.InvalidValue, args.parseCLI(",precision:1,discrete:3")); + } + + test "format entry MouseScrollMultiplier" { + const testing = std.testing; + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + + var args: Self = .{ .precision = 1.5, .discrete = 2.5 }; + try args.formatEntry(formatterpkg.entryFormatter("mouse-scroll-multiplier", buf.writer())); + try testing.expectEqualSlices(u8, "mouse-scroll-multiplier = precision:1.5,discrete:2.5\n", buf.items); + } }; /// How to treat requests to write to or read from the clipboard From 10316297412e9a57dbfbdc19b7fdff66e17b2a60 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 10:00:17 -0700 Subject: [PATCH 48/52] config: modify MouseScrollMultiplier to lean on args parsing --- src/cli/args.zig | 37 +++++++-- src/config/Config.zig | 185 ++++++++++++++++-------------------------- 2 files changed, 102 insertions(+), 120 deletions(-) diff --git a/src/cli/args.zig b/src/cli/args.zig index 2d2d199be..b8f393864 100644 --- a/src/cli/args.zig +++ b/src/cli/args.zig @@ -507,13 +507,18 @@ pub fn parseTaggedUnion(comptime T: type, alloc: Allocator, v: []const u8) !T { fn parseStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { return switch (@typeInfo(T).@"struct".layout) { - .auto => parseAutoStruct(T, alloc, v), + .auto => parseAutoStruct(T, alloc, v, null), .@"packed" => parsePackedStruct(T, v), else => @compileError("unsupported struct layout"), }; } -pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { +pub fn parseAutoStruct( + comptime T: type, + alloc: Allocator, + v: []const u8, + default_: ?T, +) !T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .auto); @@ -573,9 +578,18 @@ pub fn parseAutoStruct(comptime T: type, alloc: Allocator, v: []const u8) !T { // Ensure all required fields are set inline for (info.fields, 0..) |field, i| { if (!fields_set.isSet(i)) { - const default_ptr = field.default_value_ptr orelse return error.InvalidValue; - const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr)); - @field(result, field.name) = typed_ptr.*; + @field(result, field.name) = default: { + // If we're given a default value then we inherit those. + // Otherwise we use the default values as specified by the + // struct. + if (default_) |default| { + break :default @field(default, field.name); + } else { + const default_ptr = field.default_value_ptr orelse return error.InvalidValue; + const typed_ptr: *const field.type = @alignCast(@ptrCast(default_ptr)); + break :default typed_ptr.*; + } + }; } } @@ -1194,7 +1208,18 @@ test "parseIntoField: struct with basic fields" { try testing.expectEqual(84, data.value.b); try testing.expectEqual(24, data.value.c); - // Missing require dfield + // Set with explicit default + data.value = try parseAutoStruct( + @TypeOf(data.value), + alloc, + "a:hello", + .{ .a = "oh no", .b = 42 }, + ); + try testing.expectEqualStrings("hello", data.value.a); + try testing.expectEqual(42, data.value.b); + try testing.expectEqual(12, data.value.c); + + // Missing required field try testing.expectError( error.InvalidValue, parseIntoField(@TypeOf(data), alloc, &data, "value", "a:hello"), diff --git a/src/config/Config.zig b/src/config/Config.zig index 63db07235..fdea944ad 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -833,15 +833,20 @@ palette: Palette = .{}, /// * `never` @"mouse-shift-capture": MouseShiftCapture = .false, -/// Multiplier for scrolling distance with the mouse wheel. Any value less -/// than 0.01 (0.01 for precision scroll) or greater than 10,000 will be clamped -/// to the nearest valid value. +/// Multiplier for scrolling distance with the mouse wheel. /// -/// A discrete value of "3" (default) scrolls about 3 lines per wheel tick. -/// And a precision value of "0.1" (default) scales pixel-level scrolling. +/// A prefix of `precision:` or `discrete:` can be used to set the multiplier +/// only for scrolling with the specific type of devices. These can be +/// comma-separated to set both types of multipliers at the same time, e.g. +/// `precision:0.1,discrete:3`. If no prefix is used, the multiplier applies +/// to all scrolling devices. Specifying a prefix was introduced in Ghostty +/// 1.2.1. /// -/// Available since: 1.2.1 -@"mouse-scroll-multiplier": MouseScrollMultiplier = .{ .precision = 0.1, .discrete = 3.0 }, +/// The value will be clamped to [0.01, 10,000]. Both of these are extreme +/// and you're likely to have a bad experience if you set either extreme. +/// +/// The default value is "3" for discrete devices and "1" for precision devices. +@"mouse-scroll-multiplier": MouseScrollMultiplier = .default, /// The opacity level (opposite of transparency) of the background. A value of /// 1 is fully opaque and a value of 0 is fully transparent. A value less than 0 @@ -4078,7 +4083,7 @@ pub fn finalize(self: *Config) !void { } // Clamp our mouse scroll multiplier - self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.1, self.@"mouse-scroll-multiplier".precision)); + self.@"mouse-scroll-multiplier".precision = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".precision)); self.@"mouse-scroll-multiplier".discrete = @min(10_000.0, @max(0.01, self.@"mouse-scroll-multiplier".discrete)); // Clamp our split opacity @@ -7012,6 +7017,7 @@ pub const RepeatableCommand = struct { inputpkg.Command, alloc, input, + null, ); try self.value.append(alloc, cmd); } @@ -7325,86 +7331,31 @@ pub const MouseShiftCapture = enum { pub const MouseScrollMultiplier = struct { const Self = @This(); - precision: f64, - discrete: f64, + precision: f64 = 1, + discrete: f64 = 3, - pub fn parseCLI(self: *Self, input_: ?[]const u8) !void { - const input_raw = input_ orelse return error.ValueRequired; - const whitespace = " \t"; - const input = std.mem.trim(u8, input_raw, whitespace); - if (input.len == 0) return error.ValueRequired; + pub const default: MouseScrollMultiplier = .{}; - const value = std.fmt.parseFloat(f64, input) catch null; - if (value) |val| { - self.precision = val; - self.discrete = val; - return; - } - - const comma_idx = std.mem.indexOf(u8, input, ","); - if (comma_idx) |idx| { - if (std.mem.indexOf(u8, input[idx + 1 ..], ",")) |_| return error.InvalidValue; - - const lhs = std.mem.trim(u8, input[0..idx], whitespace); - const rhs = std.mem.trim(u8, input[idx + 1 ..], whitespace); - if (lhs.len == 0 or rhs.len == 0) return error.InvalidValue; - - const lcolon_idx = std.mem.indexOf(u8, lhs, ":") orelse return error.InvalidValue; - const rcolon_idx = std.mem.indexOf(u8, rhs, ":") orelse return error.InvalidValue; - const lkey = lhs[0..lcolon_idx]; - const lvalstr = std.mem.trim(u8, lhs[lcolon_idx + 1 ..], whitespace); - const rkey = rhs[0..rcolon_idx]; - const rvalstr = std.mem.trim(u8, rhs[rcolon_idx + 1 ..], whitespace); - - // Only "precision" and "discrete" are valid keys. They - // must be different. - if (std.mem.eql(u8, lkey, rkey)) return error.InvalidValue; - - var found_precision = false; - var found_discrete = false; - var precision_val = self.precision; - var discrete_val = self.discrete; - - if (std.mem.eql(u8, lkey, "precision")) { - precision_val = std.fmt.parseFloat(f64, lvalstr) catch return error.InvalidValue; - found_precision = true; - } else if (std.mem.eql(u8, lkey, "discrete")) { - discrete_val = std.fmt.parseFloat(f64, lvalstr) catch return error.InvalidValue; - found_discrete = true; - } else return error.InvalidValue; - - if (std.mem.eql(u8, rkey, "precision")) { - precision_val = std.fmt.parseFloat(f64, rvalstr) catch return error.InvalidValue; - found_precision = true; - } else if (std.mem.eql(u8, rkey, "discrete")) { - discrete_val = std.fmt.parseFloat(f64, rvalstr) catch return error.InvalidValue; - found_discrete = true; - } else return error.InvalidValue; - - if (!found_precision or !found_discrete) return error.InvalidValue; - if (precision_val == 0 or discrete_val == 0) return error.InvalidValue; - - self.precision = precision_val; - self.discrete = discrete_val; - - return; - } else { - const colon_idx = std.mem.indexOf(u8, input, ":") orelse return error.InvalidValue; - const key = input[0..colon_idx]; - const valstr = std.mem.trim(u8, input[colon_idx + 1 ..], whitespace); - if (valstr.len == 0) return error.InvalidValue; - - const val = std.fmt.parseFloat(f64, valstr) catch return error.InvalidValue; - if (val == 0) return error.InvalidValue; - - if (std.mem.eql(u8, key, "precision")) { - self.precision = val; - return; - } else if (std.mem.eql(u8, key, "discrete")) { - self.discrete = val; - return; - } else return error.InvalidValue; - } + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + const input = input_ orelse return error.ValueRequired; + self.* = cli.args.parseAutoStruct( + MouseScrollMultiplier, + alloc, + input, + self.*, + ) catch |err| switch (err) { + error.InvalidValue => bare: { + const v = std.fmt.parseFloat( + f64, + input, + ) catch return error.InvalidValue; + break :bare .{ + .precision = v, + .discrete = v, + }; + }, + else => return err, + }; } /// Deep copy of the struct. Required by Config. @@ -7421,45 +7372,50 @@ pub const MouseScrollMultiplier = struct { /// Used by Formatter pub fn formatEntry(self: Self, formatter: anytype) !void { var buf: [32]u8 = undefined; - const formatted = try std.fmt.bufPrint(&buf, "precision:{d},discrete:{d}", .{ self.precision, self.discrete }); + const formatted = std.fmt.bufPrint( + &buf, + "precision:{d},discrete:{d}", + .{ self.precision, self.discrete }, + ) catch return error.OutOfMemory; try formatter.formatEntry([]const u8, formatted); } - test "parse MouseScrollMultiplier" { + test "parse" { const testing = std.testing; + const alloc = testing.allocator; + const epsilon = 0.00001; var args: Self = .{ .precision = 0.1, .discrete = 3 }; - try args.parseCLI("3"); - try testing.expect(args.precision == 3 and args.discrete == 3); - args = .{ .precision = 0.1, .discrete = 3 }; - try args.parseCLI("precision:1"); - try testing.expect(args.precision == 1 and args.discrete == 3); - args = .{ .precision = 0.1, .discrete = 3 }; - try args.parseCLI("discrete:5"); - try testing.expect(args.precision == 0.1 and args.discrete == 5); - args = .{ .precision = 0.1, .discrete = 3 }; - try args.parseCLI("precision:3,discrete:7"); - try testing.expect(args.precision == 3 and args.discrete == 7); - args = .{ .precision = 0.1, .discrete = 3 }; - try args.parseCLI("discrete:8,precision:6"); - try testing.expect(args.precision == 6 and args.discrete == 8); + try args.parseCLI(alloc, "3"); + try testing.expectApproxEqAbs(3, args.precision, epsilon); + try testing.expectApproxEqAbs(3, args.discrete, epsilon); args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("foo:1")); + try args.parseCLI(alloc, "precision:1"); + try testing.expectApproxEqAbs(1, args.precision, epsilon); + try testing.expectApproxEqAbs(3, args.discrete, epsilon); + args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("precision:bar")); + try args.parseCLI(alloc, "discrete:5"); + try testing.expectApproxEqAbs(0.1, args.precision, epsilon); + try testing.expectApproxEqAbs(5, args.discrete, epsilon); + args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,precision:3")); + try args.parseCLI(alloc, "precision:3,discrete:7"); + try testing.expectApproxEqAbs(3, args.precision, epsilon); + try testing.expectApproxEqAbs(7, args.discrete, epsilon); + args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.ValueRequired, args.parseCLI("")); - args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,discrete:3,foo:5")); - args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,,discrete:3")); - args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI("precision:1,discrete:3,")); - args = .{ .precision = 0.1, .discrete = 3 }; - try testing.expectError(error.InvalidValue, args.parseCLI(",precision:1,discrete:3")); + try args.parseCLI(alloc, "discrete:8,precision:6"); + try testing.expectApproxEqAbs(6, args.precision, epsilon); + try testing.expectApproxEqAbs(8, args.discrete, epsilon); + + args = .default; + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "foo:1")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:bar")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,discrete:3,foo:5")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, "precision:1,,discrete:3")); + try testing.expectError(error.InvalidValue, args.parseCLI(alloc, ",precision:1,discrete:3")); } test "format entry MouseScrollMultiplier" { @@ -8087,6 +8043,7 @@ pub const Theme = struct { Theme, alloc, input, + null, ); return; } From 3fdb52e48d528ad80698d59c4194c13e84eff51e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 10:48:16 -0700 Subject: [PATCH 49/52] apprt/gtk: do not close window if tab overview is open with no tabs Fixes #8944 When we drag the only tab out of the tab overview, this triggers an `n-pages` signal with 0 pages. If we close the window in this state, it causes both Ghostty to exit AND the drag/drop to fail. Even if we pre-empt Ghostty exiting by modifying the application class, the drag/drop still fails and the application leaks memory and enters a bad state. The solution is to keep the window open if we go to `n-pages == 0` and we have the tab overview open. Interestingly, if you click to close the final tab from the tab overview, Adwaita closes the tab overview so it still triggers the window closing behavior (this is good). --- src/apprt/gtk/class/application.zig | 10 ++++++++-- src/apprt/gtk/class/window.zig | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 6ab3ad282..f7ed0d38c 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -524,13 +524,19 @@ pub const Application = extern struct { if (!config.@"quit-after-last-window-closed") break :q false; // If the quit timer has expired, quit. - if (priv.quit_timer == .expired) break :q true; + if (priv.quit_timer == .expired) { + log.debug("must_quit due to quit timer expired", .{}); + break :q true; + } // If we have no windows attached to our app, also quit. if (priv.requested_window and @as( ?*glib.List, self.as(gtk.Application).getWindows(), - ) == null) break :q true; + ) == null) { + log.debug("must_quit due to no app windows", .{}); + break :q true; + } // No quit conditions met break :q false; diff --git a/src/apprt/gtk/class/window.zig b/src/apprt/gtk/class/window.zig index df6ea647f..c0dd6ab1f 100644 --- a/src/apprt/gtk/class/window.zig +++ b/src/apprt/gtk/class/window.zig @@ -1489,6 +1489,13 @@ pub const Window = extern struct { const priv = self.private(); if (priv.tab_view.getNPages() == 0) { // If we have no pages left then we want to close window. + + // If the tab overview is open, then we don't close the window + // because its a rather abrupt experience. This also fixes an + // issue where dragging out the last tab in the tab overview + // won't cause Ghostty to exit. + if (priv.tab_overview.getOpen() != 0) return; + self.as(gtk.Window).close(); } } From e3ebdc79756985ee541b6b1ed402da596b39318b Mon Sep 17 00:00:00 2001 From: Daniel Wennberg Date: Sat, 6 Sep 2025 19:21:25 -0700 Subject: [PATCH 50/52] Rewrite constraint code for improved icon scaling/alignment --- src/config/Config.zig | 9 +- src/font/Collection.zig | 13 +- src/font/Metrics.zig | 109 ++-- src/font/SharedGrid.zig | 8 +- src/font/face.zig | 389 ++++++------ src/font/face/coretext.zig | 21 +- src/font/face/freetype.zig | 65 +- src/font/nerd_font_attributes.zig | 944 ++++++++++++++---------------- src/font/nerd_font_codegen.py | 86 +-- src/renderer/generic.zig | 3 +- 10 files changed, 819 insertions(+), 828 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index fdea944ad..46eb03fe2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -416,9 +416,12 @@ pub const compatibility = std.StaticStringMap( /// necessarily force them to be. Decreasing this value will make nerd font /// icons smaller. /// -/// The default value for the icon height is 1.2 times the height of capital -/// letters in your primary font, so something like -16.6% would make icons -/// roughly the same height as capital letters. +/// This value only applies to icons that are constrained to a single cell by +/// neighboring characters. An icon that is free to spread across two cells +/// can always use up to the full line height of the primary font. +/// +/// The default value is 2/3 times the height of capital letters in your primary +/// font plus 1/3 times the font's line height. /// /// See the notes about adjustments in `adjust-cell-width`. /// diff --git a/src/font/Collection.zig b/src/font/Collection.zig index ad9590d70..997c72aa7 100644 --- a/src/font/Collection.zig +++ b/src/font/Collection.zig @@ -1213,6 +1213,9 @@ test "metrics" { // and 1em should be the point size * dpi scale, so 12 * (96/72) // which is 16, and 16 * 1.049 = 16.784, which finally is rounded // to 17. + // + // The icon height is (2 * cap_height + face_height) / 3 + // = (2 * 623 + 1049) / 3 = 765, and 16 * 0.765 = 12.24. .cell_height = 17, .cell_baseline = 3, .underline_position = 17, @@ -1223,7 +1226,10 @@ test "metrics" { .overline_thickness = 1, .box_thickness = 1, .cursor_height = 17, - .icon_height = 11, + .icon_height = 12.24, + .face_width = 8.0, + .face_height = 16.784, + .face_y = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value }, c.metrics); // Resize should change metrics @@ -1240,7 +1246,10 @@ test "metrics" { .overline_thickness = 2, .box_thickness = 2, .cursor_height = 34, - .icon_height = 23, + .icon_height = 24.48, + .face_width = 16.0, + .face_height = 33.568, + .face_y = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value }, c.metrics); } diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index 9f6df9dc3..a0bc047c4 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -36,11 +36,17 @@ cursor_thickness: u32 = 1, cursor_height: u32, /// The constraint height for nerd fonts icons. -icon_height: u32, +icon_height: f64, -/// Original cell width in pixels. This is used to keep -/// glyphs centered if the cell width is adjusted wider. -original_cell_width: ?u32 = null, +/// The unrounded face width, used in scaling calculations. +face_width: f64, + +/// The unrounded face height, used in scaling calculations. +face_height: f64, + +/// The vertical bearing of face within the pixel-rounded +/// and possibly height-adjusted cell +face_y: f64, /// Minimum acceptable values for some fields to prevent modifiers /// from being able to, for example, cause 0-thickness underlines. @@ -53,7 +59,9 @@ const Minimums = struct { const box_thickness = 1; const cursor_thickness = 1; const cursor_height = 1; - const icon_height = 1; + const icon_height = 1.0; + const face_height = 1.0; + const face_width = 1.0; }; /// Metrics extracted from a font face, based on @@ -214,8 +222,10 @@ pub fn calc(face: FaceMetrics) Metrics { // We use the ceiling of the provided cell width and height to ensure // that the cell is large enough for the provided size, since we cast // it to an integer later. - const cell_width = @ceil(face.cell_width); - const cell_height = @ceil(face.lineHeight()); + const face_width = face.cell_width; + const face_height = face.lineHeight(); + const cell_width = @ceil(face_width); + const cell_height = @ceil(face_height); // We split our line gap in two parts, and put half of it on the top // of the cell and the other half on the bottom, so that our text never @@ -224,7 +234,11 @@ pub fn calc(face: FaceMetrics) Metrics { // Unlike all our other metrics, `cell_baseline` is relative to the // BOTTOM of the cell. - const cell_baseline = @round(half_line_gap - face.descent); + const face_baseline = half_line_gap - face.descent; + const cell_baseline = @round(face_baseline); + + // We keep track of the vertical bearing of the face in the cell + const face_y = cell_baseline - face_baseline; // We calculate a top_to_baseline to make following calculations simpler. const top_to_baseline = cell_height - cell_baseline; @@ -237,16 +251,8 @@ pub fn calc(face: FaceMetrics) Metrics { const underline_position = @round(top_to_baseline - face.underlinePosition()); const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition()); - // The calculation for icon height in the nerd fonts patcher - // is two thirds cap height to one third line height, but we - // use an opinionated default of 1.2 * cap height instead. - // - // Doing this prevents fonts with very large line heights - // from having excessively oversized icons, and allows fonts - // with very small line heights to still have roomy icons. - // - // We do cap it at `cell_height` though for obvious reasons. - const icon_height = @min(cell_height, cap_height * 1.2); + // Same heuristic as the font_patcher script + const icon_height = (2 * cap_height + face_height) / 3; var result: Metrics = .{ .cell_width = @intFromFloat(cell_width), @@ -260,7 +266,10 @@ pub fn calc(face: FaceMetrics) Metrics { .overline_thickness = @intFromFloat(underline_thickness), .box_thickness = @intFromFloat(underline_thickness), .cursor_height = @intFromFloat(cell_height), - .icon_height = @intFromFloat(icon_height), + .icon_height = icon_height, + .face_width = face_width, + .face_height = face_height, + .face_y = face_y, }; // Ensure all metrics are within their allowable range. @@ -286,11 +295,6 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { const new = @max(entry.value_ptr.apply(original), 1); if (new == original) continue; - // Preserve the original cell width if not set. - if (self.original_cell_width == null) { - self.original_cell_width = self.cell_width; - } - // Set the new value @field(self, @tagName(tag)) = new; @@ -307,6 +311,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { const diff = new - original; const diff_bottom = diff / 2; const diff_top = diff - diff_bottom; + self.face_y += @floatFromInt(diff_bottom); self.cell_baseline +|= diff_bottom; self.underline_position +|= diff_top; self.strikethrough_position +|= diff_top; @@ -315,6 +320,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void { const diff = original - new; const diff_bottom = diff / 2; const diff_top = diff - diff_bottom; + self.face_y -= @floatFromInt(diff_bottom); self.cell_baseline -|= diff_bottom; self.underline_position -|= diff_top; self.strikethrough_position -|= diff_top; @@ -417,25 +423,35 @@ pub const Modifier = union(enum) { /// Apply a modifier to a numeric value. pub fn apply(self: Modifier, v: anytype) @TypeOf(v) { const T = @TypeOf(v); - const signed = @typeInfo(T).int.signedness == .signed; - return switch (self) { - .percent => |p| percent: { - const p_clamped: f64 = @max(0, p); - const v_f64: f64 = @floatFromInt(v); - const applied_f64: f64 = @round(v_f64 * p_clamped); - const applied_T: T = @intFromFloat(applied_f64); - break :percent applied_T; - }, + const Tinfo = @typeInfo(T); + return switch (comptime Tinfo) { + .int, .comptime_int => switch (self) { + .percent => |p| percent: { + const p_clamped: f64 = @max(0, p); + const v_f64: f64 = @floatFromInt(v); + const applied_f64: f64 = @round(v_f64 * p_clamped); + const applied_T: T = @intFromFloat(applied_f64); + break :percent applied_T; + }, - .absolute => |abs| absolute: { - const v_i64: i64 = @intCast(v); - const abs_i64: i64 = @intCast(abs); - const applied_i64: i64 = v_i64 +| abs_i64; - const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64); - const applied_T: T = std.math.cast(T, clamped_i64) orelse - std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64))); - break :absolute applied_T; + .absolute => |abs| absolute: { + const v_i64: i64 = @intCast(v); + const abs_i64: i64 = @intCast(abs); + const applied_i64: i64 = v_i64 +| abs_i64; + const clamped_i64: i64 = if (Tinfo.int.signedness == .signed) + applied_i64 + else + @max(0, applied_i64); + const applied_T: T = std.math.cast(T, clamped_i64) orelse + std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64))); + break :absolute applied_T; + }, }, + .float, .comptime_float => return switch (self) { + .percent => |p| v * @max(0, p), + .absolute => |abs| v + @as(T, @floatFromInt(abs)), + }, + else => {}, }; } @@ -481,7 +497,7 @@ pub const Key = key: { var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined; var count: usize = 0; for (field_infos, 0..) |field, i| { - if (field.type != u32 and field.type != i32) continue; + if (field.type != u32 and field.type != i32 and field.type != f64) continue; enumFields[i] = .{ .name = field.name, .value = i }; count += 1; } @@ -512,7 +528,10 @@ fn init() Metrics { .overline_thickness = 0, .box_thickness = 0, .cursor_height = 0, - .icon_height = 0, + .icon_height = 0.0, + .face_width = 0.0, + .face_height = 0.0, + .face_y = 0.0, }; } @@ -542,6 +561,7 @@ test "Metrics: adjust cell height smaller" { try set.put(alloc, .cell_height, .{ .percent = 0.75 }); var m: Metrics = init(); + m.face_y = 0.33; m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; @@ -549,6 +569,7 @@ test "Metrics: adjust cell height smaller" { m.cell_height = 100; m.cursor_height = 100; m.apply(set); + try testing.expectEqual(-11.67, m.face_y); try testing.expectEqual(@as(u32, 75), m.cell_height); try testing.expectEqual(@as(u32, 38), m.cell_baseline); try testing.expectEqual(@as(u32, 42), m.underline_position); @@ -570,6 +591,7 @@ test "Metrics: adjust cell height larger" { try set.put(alloc, .cell_height, .{ .percent = 1.75 }); var m: Metrics = init(); + m.face_y = 0.33; m.cell_baseline = 50; m.underline_position = 55; m.strikethrough_position = 30; @@ -577,6 +599,7 @@ test "Metrics: adjust cell height larger" { m.cell_height = 100; m.cursor_height = 100; m.apply(set); + try testing.expectEqual(37.33, m.face_y); try testing.expectEqual(@as(u32, 175), m.cell_height); try testing.expectEqual(@as(u32, 87), m.cell_baseline); try testing.expectEqual(@as(u32, 93), m.underline_position); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index e79fd117f..3fd9cf204 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -270,11 +270,9 @@ pub fn renderGlyph( // Always use these constraints for emoji. if (p == .emoji) { render_opts.constraint = .{ - // Make the emoji as wide as possible, scaling proportionally, - // but then scale it down as necessary if its new size exceeds - // the cell height. - .size_horizontal = .cover, - .size_vertical = .fit, + // Scale emoji to be as large as possible + // while preserving their aspect ratio. + .size = .cover, // Center the emoji in its cells. .align_horizontal = .center, diff --git a/src/font/face.zig b/src/font/face.zig index 9da3c30f6..0f882a77f 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -136,10 +136,8 @@ pub const RenderOptions = struct { /// Don't constrain the glyph in any way. pub const none: Constraint = .{}; - /// Vertical sizing rule. - size_vertical: Size = .none, - /// Horizontal sizing rule. - size_horizontal: Size = .none, + /// Sizing rule. + size: Size = .none, /// Vertical alignment rule. align_vertical: Align = .none, @@ -155,42 +153,40 @@ pub const RenderOptions = struct { /// Bottom padding when resizing. pad_bottom: f64 = 0.0, - // This acts as a multiple of the provided width when applying - // constraints, so if this is 1.6 for example, then a width of - // 10 would be treated as though it were 16. - group_width: f64 = 1.0, - // This acts as a multiple of the provided height when applying - // constraints, so if this is 1.6 for example, then a height of - // 10 would be treated as though it were 16. - group_height: f64 = 1.0, - // This is an x offset for the actual width within the group width. - // If this is 0.5 then the glyph will be offset so that its left - // edge sits at the halfway point of the group width. - group_x: f64 = 0.0, - // This is a y offset for the actual height within the group height. - // If this is 0.5 then the glyph will be offset so that its bottom - // edge sits at the halfway point of the group height. - group_y: f64 = 0.0, + // Size and bearings of the glyph relative + // to the bounding box of its scale group. + relative_width: f64 = 1.0, + relative_height: f64 = 1.0, + relative_x: f64 = 0.0, + relative_y: f64 = 0.0, - /// Maximum ratio of width to height when resizing. + /// Maximum aspect ratio (width/height) to allow when stretching. max_xy_ratio: ?f64 = null, /// Maximum number of cells horizontally to use. max_constraint_width: u2 = 2, - /// What to use as the height metric when constraining the glyph. + /// What to use as the height metric when constraining the glyph and + /// the constraint width is 1, height: Height = .cell, pub const Size = enum { /// Don't change the size of this glyph. none, - /// Move the glyph and optionally scale it down - /// proportionally to fit within the given axis. + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. fit, - /// Move and resize the glyph proportionally to - /// cover the given axis. + /// Scale the glyph up or down to exactly match the bounds, + /// preserving aspect ratio. cover, - /// Same as `cover` but not proportional. + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. If the glyph doesn't cover a + /// single cell, scale up. If the glyph exceeds a single + /// cell but is within the bounds, do nothing. + /// (Nerd Font specific rule.) + fit_cover1, + /// Stretch the glyph to exactly fit the bounds in both + /// directions, disregarding aspect ratio. stretch, }; @@ -205,12 +201,18 @@ pub const RenderOptions = struct { end, /// Move the glyph so that it is centered on this axis. center, + /// Move the glyph so that it is centered on this axis, + /// but always with respect to the first cell even for + /// multi-cell constraints. (Nerd Font specific rule.) + center1, }; pub const Height = enum { - /// Use the full height of the cell for constraining this glyph. + /// Always use the full height of the cell for constraining this glyph. cell, - /// Use the "icon height" from the grid metrics as the height. + /// When the constraint width is 1, use the "icon height" from the grid + /// metrics as the height. (When the constraint width is >1, the + /// constraint height is always the full cell height.) icon, }; @@ -226,9 +228,8 @@ pub const RenderOptions = struct { /// because it neither sizes nor positions the glyph, then this /// returns false. pub inline fn doesAnything(self: Constraint) bool { - return self.size_horizontal != .none or + return self.size != .none or self.align_horizontal != .none or - self.size_vertical != .none or self.align_vertical != .none; } @@ -241,156 +242,202 @@ pub const RenderOptions = struct { /// Number of cells horizontally available for this glyph. constraint_width: u2, ) GlyphSize { - var g = glyph; + if (!self.doesAnything()) return glyph; - const available_width: f64 = @floatFromInt( - metrics.cell_width * @min( - self.max_constraint_width, - constraint_width, - ), - ); - const available_height: f64 = @floatFromInt(switch (self.height) { - .cell => metrics.cell_height, - .icon => metrics.icon_height, - }); + // For extra wide font faces, never stretch glyphs across two cells. + // This mirrors font_patcher. + const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width); - const w = available_width - - self.pad_left * available_width - - self.pad_right * available_width; - const h = available_height - - self.pad_top * available_height - - self.pad_bottom * available_height; - - // Subtract padding from the bearings so that our - // alignment and sizing code works correctly. We - // re-add before returning. - g.x -= self.pad_left * available_width; - g.y -= self.pad_bottom * available_height; - - // Multiply by group width and height for better sizing. - g.width *= self.group_width; - g.height *= self.group_height; - - switch (self.size_horizontal) { - .none => {}, - .fit => if (g.width > w) { - const orig_height = g.height; - // Adjust our height and width to proportionally - // scale them to fit the glyph to the cell width. - g.height *= w / g.width; - g.width = w; - // Set our x to 0 since anything else would mean - // the glyph extends outside of the cell width. - g.x = 0; - // Compensate our y to keep things vertically - // centered as they're scaled down. - g.y += (orig_height - g.height) / 2; - } else if (g.width + g.x > w) { - // If the width of the glyph can fit in the cell but - // is currently outside due to the left bearing, then - // we reduce the left bearing just enough to fit it - // back in the cell. - g.x = w - g.width; - } else if (g.x < 0) { - g.x = 0; - }, - .cover => { - const orig_height = g.height; - - g.height *= w / g.width; - g.width = w; - - g.x = 0; - - g.y += (orig_height - g.height) / 2; - }, - .stretch => { - g.width = w; - g.x = 0; - }, - } - - switch (self.size_vertical) { - .none => {}, - .fit => if (g.height > h) { - const orig_width = g.width; - // Adjust our height and width to proportionally - // scale them to fit the glyph to the cell height. - g.width *= h / g.height; - g.height = h; - // Set our y to 0 since anything else would mean - // the glyph extends outside of the cell height. - g.y = 0; - // Compensate our x to keep things horizontally - // centered as they're scaled down. - g.x += (orig_width - g.width) / 2; - } else if (g.height + g.y > h) { - // If the height of the glyph can fit in the cell but - // is currently outside due to the bottom bearing, then - // we reduce the bottom bearing just enough to fit it - // back in the cell. - g.y = h - g.height; - } else if (g.y < 0) { - g.y = 0; - }, - .cover => { - const orig_width = g.width; - - g.width *= h / g.height; - g.height = h; - - g.y = 0; - - g.x += (orig_width - g.width) / 2; - }, - .stretch => { - g.height = h; - g.y = 0; - }, - } - - // Add group-relative position - g.x += self.group_x * g.width; - g.y += self.group_y * g.height; - - // Divide group width and height back out before we align. - g.width /= self.group_width; - g.height /= self.group_height; - - if (self.max_xy_ratio) |ratio| if (g.width > g.height * ratio) { - const orig_width = g.width; - g.width = g.height * ratio; - g.x += (orig_width - g.width) / 2; + // The bounding box for the glyph's scale group. + // Scaling and alignment rules are calculated for + // this box and then applied to the glyph. + var group: GlyphSize = group: { + const group_width = glyph.width / self.relative_width; + const group_height = glyph.height / self.relative_height; + break :group .{ + .width = group_width, + .height = group_height, + .x = glyph.x - (group_width * self.relative_x), + .y = glyph.y - (group_height * self.relative_y), + }; }; - switch (self.align_horizontal) { - .none => {}, - .start => g.x = 0, - .end => g.x = w - g.width, - .center => g.x = (w - g.width) / 2, + // The new, constrained glyph size + var constrained_glyph = glyph; + + // Apply prescribed scaling + const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); + constrained_glyph.width *= width_factor; + constrained_glyph.x *= width_factor; + constrained_glyph.height *= height_factor; + constrained_glyph.y *= height_factor; + + // NOTE: font_patcher jumps through a lot of hoops at this + // point to ensure that the glyph remains within the target + // bounding box after rounding to font definition units. + // This is irrelevant here as we're not rounding, we're + // staying in f64 and heading straight to rendering. + + // Align vertically + if (self.align_vertical != .none) { + // Vertically scale group bounding box. + group.height *= height_factor; + group.y *= height_factor; + + // Calculate offset and shift the glyph + constrained_glyph.y += self.offset_vertical(group, metrics); } - switch (self.align_vertical) { - .none => {}, - .start => g.y = 0, - .end => g.y = h - g.height, - .center => g.y = (h - g.height) / 2, + // Align horizontally + if (self.align_horizontal != .none) { + // Horizontally scale group bounding box. + group.width *= width_factor; + group.x *= width_factor; + + // Calculate offset and shift the glyph + constrained_glyph.x += self.offset_horizontal(group, metrics, min_constraint_width); } - // Re-add our padding before returning. - g.x += self.pad_left * available_width; - g.y += self.pad_bottom * available_height; + return constrained_glyph; + } - // If the available height is less than the cell height, we - // add half of the difference to center it in the full height. - // - // If necessary, in the future, we can adjust this to account - // for alignment, but that isn't necessary with any of the nf - // icons afaict. - const cell_height: f64 = @floatFromInt(metrics.cell_height); - g.y += (cell_height - available_height) / 2; + /// Return width and height scaling factors for this scaling group. + fn scale_factors( + self: Constraint, + group: GlyphSize, + metrics: Metrics, + min_constraint_width: u2, + ) struct { f64, f64 } { + if (self.size == .none) { + return .{ 1.0, 1.0 }; + } - return g; + const multi_cell = (min_constraint_width > 1); + + const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); + const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); + + const target_width = pad_width_factor * metrics.face_width; + const target_height = pad_height_factor * switch (self.height) { + .cell => metrics.face_height, + // icon_height only applies with single-cell constraints. + // This mirrors font_patcher. + .icon => if (multi_cell) + metrics.face_height + else + metrics.icon_height, + }; + + var width_factor = target_width / group.width; + var height_factor = target_height / group.height; + + switch (self.size) { + .none => unreachable, + .fit => { + // Scale down to fit if needed + height_factor = @min(1, width_factor, height_factor); + width_factor = height_factor; + }, + .cover => { + // Scale to cover + height_factor = @min(width_factor, height_factor); + width_factor = height_factor; + }, + .fit_cover1 => { + // Scale down to fit or up to cover at least one cell + // NOTE: This is similar to font_patcher's "pa" mode, + // however, font_patcher will only do the upscaling + // part if the constraint width is 1, resulting in + // some icons becoming smaller when the constraint + // width increases. You'd see icons shrinking when + // opening up a space after them. This makes no + // sense, so we've fixed the rule such that these + // icons are scaled to the same size for multi-cell + // constraints as they would be for single-cell. + height_factor = @min(width_factor, height_factor); + if (multi_cell and (height_factor > 1)) { + // Call back into this function with + // constraint width 1 to get single-cell scale + // factors. We use the height factor as width + // could have been modified by max_xy_ratio. + _, const single_height_factor = self.scale_factors(group, metrics, 1); + height_factor = @max(1, single_height_factor); + } + width_factor = height_factor; + }, + .stretch => {}, + } + + // Reduce aspect ratio if required + if (self.max_xy_ratio) |ratio| { + if (group.width * width_factor > group.height * height_factor * ratio) { + width_factor = group.height * height_factor * ratio / group.width; + } + } + + return .{ width_factor, height_factor }; + } + + /// Return vertical offset needed to align this group + fn offset_vertical( + self: Constraint, + group: GlyphSize, + metrics: Metrics, + ) f64 { + // We use face_height and offset by face_y, rather than + // using cell_height directly, to account for the asymmetry + // of the pixel cell around the face (a consequence of + // aligning the baseline with a pixel boundary rather than + // vertically centering the face). + const new_group_y = metrics.face_y + switch (self.align_vertical) { + .none => return 0.0, + .start => self.pad_bottom * metrics.face_height, + .end => end: { + const pad_top_dy = self.pad_top * metrics.face_height; + break :end metrics.face_height - pad_top_dy - group.height; + }, + .center, .center1 => (metrics.face_height - group.height) / 2, + }; + return new_group_y - group.y; + } + + /// Return horizontal offset needed to align this group + fn offset_horizontal( + self: Constraint, + group: GlyphSize, + metrics: Metrics, + min_constraint_width: u2, + ) f64 { + // For multi-cell constraints, we align relative to the span + // from the left edge of the first face cell to the right + // edge of the last face cell as they sit within the rounded + // and adjusted pixel cell (centered if narrower than the + // pixel cell, left-aligned if wider). + const face_x, const full_face_span = facecalcs: { + const cell_width: f64 = @floatFromInt(metrics.cell_width); + const full_width: f64 = @floatFromInt(min_constraint_width * metrics.cell_width); + const cell_margin = cell_width - metrics.face_width; + break :facecalcs .{ @max(0, cell_margin / 2), full_width - cell_margin }; + }; + const pad_left_x = self.pad_left * metrics.face_width; + const new_group_x = face_x + switch (self.align_horizontal) { + .none => return 0.0, + .start => pad_left_x, + .end => end: { + const pad_right_dx = self.pad_right * metrics.face_width; + break :end @max(pad_left_x, full_face_span - pad_right_dx - group.width); + }, + .center => @max(pad_left_x, (full_face_span - group.width) / 2), + // NOTE: .center1 implements the font_patcher rule of centering + // in the first cell even for multi-cell constraints. Since glyphs + // are not allowed to protrude to the left, this results in the + // left-alignment like .start when the glyph is wider than a cell. + .center1 => @max(pad_left_x, (metrics.face_width - group.width) / 2), + }; + return new_group_x - group.x; } }; }; diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index cb9993cbf..8c9611c04 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -388,19 +388,16 @@ pub const Face = struct { y = @round(y); } - // If the cell width was adjusted wider, we re-center all glyphs - // in the new width, so that they aren't weirdly off to the left. - if (metrics.original_cell_width) |original| recenter: { - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal != .none) break :recenter; - - // If the original width was wider then we don't do anything. - if (original >= metrics.cell_width) break :recenter; - + // We center all glyphs within the pixel-rounded and adjusted + // cell width if it's larger than the face width, so that they + // aren't weirdly off to the left. + // + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if ((opts.constraint.align_horizontal == .none) and (metrics.face_width < cell_width)) { // We add half the difference to re-center. - x += (cell_width - @as(f64, @floatFromInt(original))) / 2; + x += (cell_width - metrics.face_width) / 2; } // Our whole-pixel bearings for the final glyph. diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 82cf107c8..3094d8076 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -498,17 +498,14 @@ pub const Face = struct { y = @round(y); } - // If the cell width was adjusted wider, we re-center all glyphs - // in the new width, so that they aren't weirdly off to the left. - if (metrics.original_cell_width) |original| recenter: { - // We don't do this if the constraint has a horizontal alignment, - // since in that case the position was already calculated with the - // new cell width in mind. - if (opts.constraint.align_horizontal != .none) break :recenter; - - // If the original width was wider then we don't do anything. - if (original >= metrics.cell_width) break :recenter; - + // We center all glyphs within the pixel-rounded and adjusted + // cell width if it's larger than the face width, so that they + // aren't weirdly off to the left. + // + // We don't do this if the constraint has a horizontal alignment, + // since in that case the position was already calculated with the + // new cell width in mind. + if ((opts.constraint.align_horizontal == .none) and (metrics.face_width < cell_width)) { // We add half the difference to re-center. // // NOTE: We round this to a whole-pixel amount because under @@ -516,7 +513,7 @@ pub const Face = struct { // the case under CoreText. If we move the outlines by // a non-whole-pixel amount, it completely ruins the // hinting. - x += @round((cell_width - @as(f64, @floatFromInt(original))) / 2); + x += @round((cell_width - metrics.face_width) / 2); } // Now we can render the glyph. @@ -1211,25 +1208,31 @@ test "color emoji" { alloc, &atlas, ft_font.glyphIndex('🥸').?, - .{ .grid_metrics = .{ - .cell_width = 13, - .cell_height = 24, - .cell_baseline = 0, - .underline_position = 0, - .underline_thickness = 0, - .strikethrough_position = 0, - .strikethrough_thickness = 0, - .overline_position = 0, - .overline_thickness = 0, - .box_thickness = 0, - .cursor_height = 0, - .icon_height = 0, - }, .constraint_width = 2, .constraint = .{ - .size_horizontal = .cover, - .size_vertical = .cover, - .align_horizontal = .center, - .align_vertical = .center, - } }, + .{ + .grid_metrics = .{ + .cell_width = 13, + .cell_height = 24, + .cell_baseline = 0, + .underline_position = 0, + .underline_thickness = 0, + .strikethrough_position = 0, + .strikethrough_thickness = 0, + .overline_position = 0, + .overline_thickness = 0, + .box_thickness = 0, + .cursor_height = 0, + .icon_height = 0, + .face_width = 13, + .face_height = 24, + .face_y = 0, + }, + .constraint_width = 2, + .constraint = .{ + .size = .fit, + .align_horizontal = .center, + .align_vertical = .center, + }, + }, ); try testing.expectEqual(@as(u32, 24), glyph.height); } diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index 11902d310..04088b1aa 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -6,16 +6,15 @@ const Constraint = @import("face.zig").RenderOptions.Constraint; -/// Get the a constraints for the provided codepoint. +/// Get the constraints for the provided codepoint. pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { 0x2500...0x259f, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, - .align_horizontal = .center, - .align_vertical = .center, + .align_horizontal = .center1, + .align_vertical = .center1, .pad_left = -0.02, .pad_right = -0.02, .pad_top = -0.01, @@ -23,12 +22,11 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0x2630, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, + .size = .cover, .height = .icon, .max_constraint_width = 1, - .align_horizontal = .center, - .align_vertical = .center, + .align_horizontal = .center1, + .align_vertical = .center1, .pad_left = 0.1, .pad_right = 0.1, .pad_top = 0.1, @@ -36,49 +34,45 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0x276c...0x276d, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, + .size = .cover, .max_constraint_width = 1, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3999999999999999, - .group_height = 1.1222570532915361, - .group_x = 0.1428571428571428, - .group_y = 0.0349162011173184, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7142857142857143, + .relative_height = 0.8910614525139665, + .relative_x = 0.1428571428571428, + .relative_y = 0.0349162011173184, .pad_top = 0.15, .pad_bottom = 0.15, }, 0x276e...0x276f, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, + .size = .cover, .max_constraint_width = 1, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0115606936416186, - .group_height = 1.1222570532915361, - .group_x = 0.0057142857142857, - .group_y = 0.0125698324022346, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9885714285714285, + .relative_height = 0.8910614525139665, + .relative_x = 0.0057142857142857, + .relative_y = 0.0125698324022346, .pad_top = 0.15, .pad_bottom = 0.15, }, 0x2770...0x2771, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, + .size = .cover, .max_constraint_width = 1, - .align_horizontal = .center, - .align_vertical = .center, + .align_horizontal = .center1, + .align_vertical = .center1, .pad_top = 0.15, .pad_bottom = 0.15, }, 0xe0b0, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.06, .pad_right = -0.06, .pad_top = -0.01, @@ -87,20 +81,18 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0b1, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .max_xy_ratio = 0.7, }, 0xe0b2, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.06, .pad_right = -0.06, .pad_top = -0.01, @@ -109,20 +101,18 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0b3, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .max_xy_ratio = 0.7, }, 0xe0b4, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.06, .pad_right = -0.06, .pad_top = -0.01, @@ -131,20 +121,18 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0b5, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .max_xy_ratio = 0.5, }, 0xe0b6, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.06, .pad_right = -0.06, .pad_top = -0.01, @@ -153,21 +141,19 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0b7, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .max_xy_ratio = 0.5, }, 0xe0b8, 0xe0bc, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -176,20 +162,18 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xe0b9, 0xe0bd, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, }, 0xe0ba, 0xe0be, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -198,19 +182,17 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xe0bb, 0xe0bf, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, }, 0xe0c0, 0xe0c8, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -218,18 +200,16 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0c1, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, }, 0xe0c2, 0xe0ca, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -237,17 +217,15 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0c3, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, }, 0xe0c4, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = 0.03, .pad_right = 0.03, .pad_top = 0.03, @@ -256,10 +234,9 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0c5, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = 0.03, .pad_right = 0.03, .pad_top = 0.03, @@ -268,10 +245,9 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0c6, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = 0.03, .pad_right = 0.03, .pad_top = 0.03, @@ -280,10 +256,9 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0c7, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = 0.03, .pad_right = 0.03, .pad_top = 0.03, @@ -292,10 +267,9 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0cc, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.02, .pad_right = -0.02, .pad_top = -0.01, @@ -304,36 +278,32 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0cd, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .max_xy_ratio = 0.865, }, 0xe0ce, 0xe0d0...0xe0d1, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, + .size = .fit_cover1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, }, 0xe0cf, 0xe0d3, 0xe0d5, => .{ - .size_horizontal = .cover, - .size_vertical = .fit, - .align_horizontal = .center, - .align_vertical = .center, + .size = .fit_cover1, + .align_horizontal = .center1, + .align_vertical = .center1, }, 0xe0d2, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.02, .pad_right = -0.02, .pad_top = -0.01, @@ -342,11 +312,10 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0d4, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.02, .pad_right = -0.02, .pad_top = -0.01, @@ -355,11 +324,10 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0d6, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .start, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -368,11 +336,10 @@ pub fn getConstraint(cp: u21) ?Constraint { }, 0xe0d7, => .{ - .size_horizontal = .stretch, - .size_vertical = .stretch, + .size = .stretch, .max_constraint_width = 1, .align_horizontal = .end, - .align_vertical = .center, + .align_vertical = .center1, .pad_left = -0.05, .pad_right = -0.05, .pad_top = -0.01, @@ -425,640 +392,583 @@ pub fn getConstraint(cp: u21) ?Constraint { 0xf307...0xf847, 0xf0001...0xf1af0, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, + .align_horizontal = .center1, + .align_vertical = .center1, }, 0xea61, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3310225303292895, - .group_height = 1.0762439807383628, - .group_x = 0.0846354166666667, - .group_y = 0.0708426547352722, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7513020833333334, + .relative_height = 0.9291573452647278, + .relative_x = 0.0846354166666667, + .relative_y = 0.0708426547352722, }, 0xea7d, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1912058627581612, - .group_height = 1.1426759670259987, - .group_x = 0.0917225950782998, - .group_y = 0.0416204217536071, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8394854586129754, + .relative_height = 0.8751387347391787, + .relative_x = 0.0917225950782998, + .relative_y = 0.0416204217536071, }, 0xea99, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0642857142857143, - .group_height = 2.0929152148664345, - .group_x = 0.0302013422818792, - .group_y = 0.2269700332963374, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9395973154362416, + .relative_height = 0.4778024417314096, + .relative_x = 0.0302013422818792, + .relative_y = 0.2269700332963374, }, 0xea9a, 0xeaa1, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3032069970845481, - .group_height = 1.1731770833333333, - .group_x = 0.1526845637583893, - .group_y = 0.0754716981132075, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7673378076062640, + .relative_height = 0.8523862375138734, + .relative_x = 0.1526845637583893, + .relative_y = 0.0754716981132075, }, 0xea9b, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1640625000000000, - .group_height = 1.3134110787172011, - .group_x = 0.0721476510067114, - .group_y = 0.0871254162042175, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8590604026845637, + .relative_height = 0.7613762486126526, + .relative_x = 0.0721476510067114, + .relative_y = 0.0871254162042175, }, 0xea9c, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1640625000000000, - .group_height = 1.3201465201465201, - .group_x = 0.0721476510067114, - .group_y = 0.0832408435072142, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8590604026845637, + .relative_height = 0.7574916759156493, + .relative_x = 0.0721476510067114, + .relative_y = 0.0832408435072142, }, 0xea9d, 0xeaa0, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.4493150684931506, - .group_height = 1.9693989071038251, - .group_x = 0.2863534675615212, - .group_y = 0.2763596004439512, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4082774049217002, + .relative_height = 0.5077691453940066, + .relative_x = 0.2863534675615212, + .relative_y = 0.2763596004439512, }, 0xea9e...0xea9f, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.9540983606557376, - .group_height = 2.4684931506849317, - .group_x = 0.2136465324384788, - .group_y = 0.3068812430632630, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5117449664429530, + .relative_height = 0.4051054384017758, + .relative_x = 0.2136465324384788, + .relative_y = 0.3068812430632630, }, 0xeaa2, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.2405228758169935, - .group_height = 1.0595187680461982, - .group_x = 0.0679662802950474, - .group_y = 0.0147523709167545, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8061116965226555, + .relative_height = 0.9438247156716689, + .relative_x = 0.0679662802950474, + .relative_y = 0.0147523709167545, }, 0xeab4, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0054815974941269, - .group_height = 1.8994082840236686, - .group_y = 0.2024922118380062, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9945482866043613, + .relative_height = 0.5264797507788161, + .relative_y = 0.2024922118380062, }, 0xeab5, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.8994082840236686, - .group_height = 1.0054815974941269, - .group_x = 0.2024922118380062, - .group_y = 0.0054517133956386, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5264797507788161, + .relative_height = 0.9945482866043613, + .relative_x = 0.2024922118380062, + .relative_y = 0.0054517133956386, }, 0xeab6, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.8994082840236686, - .group_height = 1.0054815974941269, - .group_x = 0.2710280373831775, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5264797507788161, + .relative_height = 0.9945482866043613, + .relative_x = 0.2710280373831775, }, 0xeab7, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0054815974941269, - .group_height = 1.8994082840236686, - .group_x = 0.0054517133956386, - .group_y = 0.2710280373831775, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9945482866043613, + .relative_height = 0.5264797507788161, + .relative_x = 0.0054517133956386, + .relative_y = 0.2710280373831775, }, 0xead4...0xead5, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.4144620811287478, - .group_x = 0.1483790523690773, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7069825436408977, + .relative_x = 0.1483790523690773, }, 0xead6, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_height = 1.1388535031847133, - .group_y = 0.0687919463087248, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.8780760626398211, + .relative_y = 0.0687919463087248, }, 0xeb43, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3631840796019901, - .group_height = 1.0003813300793167, - .group_x = 0.1991657977059437, - .group_y = 0.0003811847221163, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7335766423357665, + .relative_height = 0.9996188152778837, + .relative_x = 0.1991657977059437, + .relative_y = 0.0003811847221163, }, 0xeb6e, 0xeb71, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_height = 2.0183246073298431, - .group_y = 0.2522697795071336, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.4954604409857328, + .relative_y = 0.2522697795071336, }, 0xeb6f, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.0104712041884816, - .group_x = 0.2493489583333333, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4973958333333333, + .relative_x = 0.2493489583333333, }, 0xeb70, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.0104712041884816, - .group_height = 1.0039062500000000, - .group_x = 0.2493489583333333, - .group_y = 0.0038910505836576, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4973958333333333, + .relative_height = 0.9961089494163424, + .relative_x = 0.2493489583333333, + .relative_y = 0.0038910505836576, }, 0xeb8a, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.8828125000000000, - .group_height = 2.9818561935339356, - .group_x = 0.2642276422764228, - .group_y = 0.3313050881410256, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.3468834688346883, + .relative_height = 0.3353615785256410, + .relative_x = 0.2642276422764228, + .relative_y = 0.3313050881410256, }, 0xeb9a, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1440626883664857, - .group_height = 1.0595187680461982, - .group_x = 0.0679662802950474, - .group_y = 0.0147523709167545, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8740779768177028, + .relative_height = 0.9438247156716689, + .relative_x = 0.0679662802950474, + .relative_y = 0.0147523709167545, }, 0xebd5, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0727069351230425, - .group_height = 1.0730882652023592, - .group_y = 0.0681102082395584, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9322210636079249, + .relative_height = 0.9318897917604415, + .relative_y = 0.0681102082395584, }, 0xebd6, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_height = 1.0003554839321263, - .group_y = 0.0003553576082064, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.9996446423917936, + .relative_y = 0.0003553576082064, }, 0xec07, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.8604846818377689, - .group_height = 2.9804665603035656, - .group_x = 0.2615335565120357, - .group_y = 0.3311487268518519, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.3495911047345768, + .relative_height = 0.3355179398148149, + .relative_x = 0.2615335565120357, + .relative_y = 0.3311487268518519, }, 0xec0b, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0721073225265512, - .group_height = 1.0003813300793167, - .group_y = 0.0003811847221163, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9327424400417101, + .relative_height = 0.9996188152778837, + .relative_y = 0.0003811847221163, }, 0xec0c, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.2486979166666667, - .group_x = 0.1991657977059437, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8008342022940563, + .relative_x = 0.1991657977059437, }, 0xf019, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, }, 0xf030, 0xf03e, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, - .group_height = 1.1426844014510278, - .group_y = 0.0624338624338624, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, + .relative_height = 0.8751322751322751, + .relative_y = 0.0624338624338624, }, 0xf03d, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_height = 1.3328631875881523, - .group_y = 0.1248677248677249, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.7502645502645503, + .relative_y = 0.1248677248677249, }, 0xf03f, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.8003104407193382, - .group_x = 0.0005406676069582, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5554597570408116, + .relative_x = 0.0005406676069582, }, 0xf040, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1263939384681190, - .group_height = 1.0007255897868335, - .group_x = 0.0003164442515641, - .group_y = 0.0001959631589261, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8877888683953564, + .relative_height = 0.9992749363119733, + .relative_x = 0.0003164442515641, + .relative_y = 0.0001959631589261, }, 0xf044, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0087313432835820, - .group_height = 1.0077472527472529, - .group_y = 0.0002010014265405, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9913442331878375, + .relative_height = 0.9923123057630445, + .relative_y = 0.0002010014265405, }, 0xf04a, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, - .group_height = 1.3321224771947897, - .group_y = 0.1247354497354497, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, + .relative_height = 0.7506817256817256, + .relative_y = 0.1247354497354497, }, 0xf051, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.7994923857868019, - .group_height = 1.3321224771947897, - .group_y = 0.1247354497354497, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5557122708039492, + .relative_height = 0.7506817256817256, + .relative_y = 0.1247354497354497, }, 0xf052, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1439802384724422, - .group_height = 1.1430071621244535, - .group_y = 0.0626172338785870, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8741409740917385, + .relative_height = 0.8748851565736010, + .relative_y = 0.0626172338785870, }, 0xf053, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 2.0025185185185186, - .group_height = 1.1416267186919362, - .group_y = 0.0620882827561120, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.4993711622401420, + .relative_height = 0.8759430588185509, + .relative_y = 0.0620882827561120, }, 0xf05a...0xf05b, 0xf0aa, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0012592592592593, - .group_height = 1.0002824582824583, - .group_y = 0.0002010014265405, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9987423244802840, + .relative_height = 0.9997176214776941, + .relative_y = 0.0002010014265405, }, 0xf071, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, - .group_height = 1.1426844014510278, - .group_x = 0.0004701457451810, - .group_y = 0.0624338624338624, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, + .relative_height = 0.8751322751322751, + .relative_x = 0.0004701457451810, + .relative_y = 0.0624338624338624, }, 0xf078, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1434320241691844, - .group_height = 2.0026841590612778, - .group_y = 0.1879786499051550, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8745600777856455, + .relative_height = 0.4993298596163721, + .relative_y = 0.1879786499051550, }, 0xf07b, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, - .group_height = 1.2285368802902055, - .group_y = 0.0930118110236220, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, + .relative_height = 0.8139763779527559, + .relative_y = 0.0930118110236220, }, 0xf081, 0xf092, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1441233373639663, - .group_height = 1.1430071621244535, - .group_y = 0.0626172338785870, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8740316426933279, + .relative_height = 0.8748851565736010, + .relative_y = 0.0626172338785870, }, 0xf08c, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.2859733978234582, - .group_height = 1.1426844014510278, - .group_y = 0.0624338624338624, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7776210625293841, + .relative_height = 0.8751322751322751, + .relative_y = 0.0624338624338624, }, 0xf09f, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.7489690176588770, - .group_x = 0.0006952841596131, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.5717654171704958, + .relative_x = 0.0006952841596131, }, 0xf0a1, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1253968253968254, - .group_height = 1.0749103295228757, - .group_y = 0.0349409448818898, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8885754583921015, + .relative_height = 0.9303101594008066, + .relative_y = 0.0349409448818898, }, 0xf0a2, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.1429529187840552, - .group_height = 1.0002824582824583, - .group_x = 0.0001253913778381, - .group_y = 0.0002010014265405, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.8749266777006549, + .relative_height = 0.9997176214776941, + .relative_x = 0.0001253913778381, + .relative_y = 0.0002010014265405, }, 0xf0a3, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0005921977940631, - .group_height = 1.0001448722153810, - .group_x = 0.0005918473033957, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9994081526966043, + .relative_height = 0.9998551487695376, + .relative_x = 0.0005918473033957, }, 0xf0a4, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0012592592592593, - .group_height = 1.3332396658348704, - .group_y = 0.1250334663306335, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9987423244802840, + .relative_height = 0.7500526916695081, + .relative_y = 0.1250334663306335, }, 0xf0ca, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0335226652102676, - .group_height = 1.2308163060897437, - .group_y = 0.0938253501046103, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9675646540335450, + .relative_height = 0.8124689241215546, + .relative_y = 0.0938253501046103, }, 0xf0d6, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_height = 1.4330042313117066, - .group_y = 0.1510826771653543, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_height = 0.6978346456692913, + .relative_y = 0.1510826771653543, }, 0xf0de, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3984670905653893, - .group_height = 2.6619718309859155, - .group_x = 0.0004030632809351, - .group_y = 0.5708994708994709, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7150686682199350, + .relative_height = 0.3756613756613756, + .relative_x = 0.0004030632809351, + .relative_y = 0.5708994708994709, }, 0xf0e7, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3348918927786344, - .group_height = 1.0001196386424678, - .group_x = 0.0006021702214782, - .group_y = 0.0001196243307751, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7491243338952770, + .relative_height = 0.9998803756692248, + .relative_x = 0.0006021702214782, + .relative_y = 0.0001196243307751, }, 0xf296, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0005202277820979, - .group_height = 1.0386597451628128, - .group_x = 0.0001795653226322, - .group_y = 0.0187142907131644, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9994800427141276, + .relative_height = 0.9627792014248586, + .relative_x = 0.0001795653226322, + .relative_y = 0.0187142907131644, }, 0xf2c4, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3292088488938882, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7523272214386461, }, 0xf2c5, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0118264574212998, - .group_height = 1.1664315937940761, - .group_x = 0.0004377219006858, - .group_y = 0.0713422007255139, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9883117728988424, + .relative_height = 0.8573155985489722, + .relative_x = 0.0004377219006858, + .relative_y = 0.0713422007255139, }, 0xf2f0, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.0012592592592593, - .group_height = 1.0342088873926949, - .group_y = 0.0165984862232646, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.9987423244802840, + .relative_height = 0.9669226518842459, + .relative_y = 0.0165984862232646, }, 0xf306, => .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit_cover1, .height = .icon, - .align_horizontal = .center, - .align_vertical = .center, - .group_width = 1.3001222493887530, + .align_horizontal = .center1, + .align_vertical = .center1, + .relative_width = 0.7691584391161260, }, else => null, }; diff --git a/src/font/nerd_font_codegen.py b/src/font/nerd_font_codegen.py index a103a30ac..4965dabe4 100644 --- a/src/font/nerd_font_codegen.py +++ b/src/font/nerd_font_codegen.py @@ -50,10 +50,10 @@ class PatchSetAttributeEntry(TypedDict): stretch: str params: dict[str, float | bool] - group_x: float - group_y: float - group_width: float - group_height: float + relative_x: float + relative_y: float + relative_width: float + relative_height: float class PatchSet(TypedDict): @@ -143,7 +143,7 @@ def parse_alignment(val: str) -> str | None: return { "l": ".start", "r": ".end", - "c": ".center", + "c": ".center1", # font-patcher specific centering rule, see face.zig "": None, }.get(val, ".none") @@ -158,10 +158,10 @@ def attr_key(attr: PatchSetAttributeEntry) -> AttributeHash: float(params.get("overlap", 0.0)), float(params.get("xy-ratio", -1.0)), float(params.get("ypadding", 0.0)), - float(attr.get("group_x", 0.0)), - float(attr.get("group_y", 0.0)), - float(attr.get("group_width", 1.0)), - float(attr.get("group_height", 1.0)), + float(attr.get("relative_x", 0.0)), + float(attr.get("relative_y", 0.0)), + float(attr.get("relative_width", 1.0)), + float(attr.get("relative_height", 1.0)), ) @@ -187,10 +187,10 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) stretch = attr.get("stretch", "") params = attr.get("params", {}) - group_x = attr.get("group_x", 0.0) - group_y = attr.get("group_y", 0.0) - group_width = attr.get("group_width", 1.0) - group_height = attr.get("group_height", 1.0) + relative_x = attr.get("relative_x", 0.0) + relative_y = attr.get("relative_y", 0.0) + relative_width = attr.get("relative_width", 1.0) + relative_height = attr.get("relative_height", 1.0) overlap = params.get("overlap", 0.0) xy_ratio = params.get("xy-ratio", -1.0) @@ -204,28 +204,30 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) s = f"{keys}\n => .{{\n" - # These translations don't quite capture the way - # the actual patcher does scaling, but they're a - # good enough compromise. - if "xy" in stretch: - s += " .size_horizontal = .stretch,\n" - s += " .size_vertical = .stretch,\n" - elif "!" in stretch or "^" in stretch: - s += " .size_horizontal = .cover,\n" - s += " .size_vertical = .fit,\n" + # This maps the font_patcher stretch rules to a Constrain instance + # NOTE: some comments in font_patcher indicate that only x or y + # would also be a valid spec, but no icons use it, so we won't + # support it until we have to. + if "pa" in stretch: + if "!" in stretch or overlap: + s += " .size = .cover,\n" + else: + s += " .size = .fit_cover1,\n" + elif "xy" in stretch: + s += " .size = .stretch,\n" else: - s += " .size_horizontal = .fit,\n" - s += " .size_vertical = .fit,\n" + print(f"Warning: Unknown stretch rule {stretch}") - # `^` indicates that scaling should fill - # the whole cell, not just the icon height. + # `^` indicates that scaling should use the + # full cell height, not just the icon height, + # even when the constraint width is 1 if "^" not in stretch: s += " .height = .icon,\n" # There are two cases where we want to limit the constraint width to 1: # - If there's a `1` in the stretch mode string. - # - If the stretch mode is `xy` and there's not an explicit `2`. - if "1" in stretch or ("xy" in stretch and "2" not in stretch): + # - If the stretch mode is not `pa` and there's not an explicit `2`. + if "1" in stretch or ("pa" not in stretch and "2" not in stretch): s += " .max_constraint_width = 1,\n" if align is not None: @@ -233,14 +235,14 @@ def emit_zig_entry_multikey(codepoints: list[int], attr: PatchSetAttributeEntry) if valign is not None: s += f" .align_vertical = {valign},\n" - if group_width != 1.0: - s += f" .group_width = {group_width:.16f},\n" - if group_height != 1.0: - s += f" .group_height = {group_height:.16f},\n" - if group_x != 0.0: - s += f" .group_x = {group_x:.16f},\n" - if group_y != 0.0: - s += f" .group_y = {group_y:.16f},\n" + if relative_width != 1.0: + s += f" .relative_width = {relative_width:.16f},\n" + if relative_height != 1.0: + s += f" .relative_height = {relative_height:.16f},\n" + if relative_x != 0.0: + s += f" .relative_x = {relative_x:.16f},\n" + if relative_y != 0.0: + s += f" .relative_y = {relative_y:.16f},\n" # `overlap` and `ypadding` are mutually exclusive, # this is asserted in the nerd fonts patcher itself. @@ -286,7 +288,7 @@ def generate_zig_switch_arms( yMin = math.inf xMax = -math.inf yMax = -math.inf - individual_bounds: dict[int, tuple[int, int, int ,int]] = {} + individual_bounds: dict[int, tuple[int, int, int, int]] = {} for cp in group: if cp not in cmap: continue @@ -306,10 +308,10 @@ def generate_zig_switch_arms( this_bounds = individual_bounds[cp] this_width = this_bounds[2] - this_bounds[0] this_height = this_bounds[3] - this_bounds[1] - entries[cp]["group_width"] = group_width / this_width - entries[cp]["group_height"] = group_height / this_height - entries[cp]["group_x"] = (this_bounds[0] - xMin) / group_width - entries[cp]["group_y"] = (this_bounds[1] - yMin) / group_height + entries[cp]["relative_width"] = this_width / group_width + entries[cp]["relative_height"] = this_height / group_height + entries[cp]["relative_x"] = (this_bounds[0] - xMin) / group_width + entries[cp]["relative_y"] = (this_bounds[1] - yMin) / group_height del entries[0] @@ -350,7 +352,7 @@ if __name__ == "__main__": const Constraint = @import("face.zig").RenderOptions.Constraint; -/// Get the a constraints for the provided codepoint. +/// Get the constraints for the provided codepoint. pub fn getConstraint(cp: u21) ?Constraint { return switch (cp) { """) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index fbc8cab99..802c769a6 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -3093,8 +3093,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // its cell(s), we don't modify the alignment at all. .constraint = getConstraint(cp) orelse if (cellpkg.isSymbol(cp)) .{ - .size_horizontal = .fit, - .size_vertical = .fit, + .size = .fit, } else .none, .constraint_width = constraintWidth(cell_pin), }, From b643d30d60e2540de6aea775b077b7208a20a7d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 29 Sep 2025 12:24:03 -0700 Subject: [PATCH 51/52] move test out of terminal to avoid lib-vt catch --- src/renderer/cell.zig | 94 ++++++++++++++++++++++++++++++++++++++++ src/terminal/Screen.zig | 95 ----------------------------------------- 2 files changed, 94 insertions(+), 95 deletions(-) diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index d54e98811..46e660bfd 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -513,3 +513,97 @@ test "Contents with zero-sized screen" { c.setCursor(null, null); try testing.expect(c.getCursorGlyph() == null); } + +test "Screen cell constraint widths" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try terminal.Screen.init(alloc, 4, 1, 0); + defer s.deinit(); + + // for each case, the numbers in the comment denote expected + // constraint widths for the symbol-containing cells + + // symbol->nothing: 2 + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(2, constraintWidth(p0)); + s.reset(); + } + + // symbol->character: 1 + { + try s.testWriteString("z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, constraintWidth(p0)); + s.reset(); + } + + // symbol->space: 2 + { + try s.testWriteString(" z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(2, constraintWidth(p0)); + s.reset(); + } + // symbol->no-break space: 1 + { + try s.testWriteString("\u{00a0}z"); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, constraintWidth(p0)); + s.reset(); + } + + // symbol->end of row: 1 + { + try s.testWriteString(" "); + const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; + try testing.expectEqual(1, constraintWidth(p3)); + s.reset(); + } + + // character->symbol: 2 + { + try s.testWriteString("z"); + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(2, constraintWidth(p1)); + s.reset(); + } + + // symbol->symbol: 1,1 + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(1, constraintWidth(p0)); + try testing.expectEqual(1, constraintWidth(p1)); + s.reset(); + } + + // symbol->space->symbol: 2,2 + { + try s.testWriteString(" "); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; + try testing.expectEqual(2, constraintWidth(p0)); + try testing.expectEqual(2, constraintWidth(p2)); + s.reset(); + } + + // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) + { + try s.testWriteString(""); + const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(1, constraintWidth(p0)); + s.reset(); + } + + // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) + { + try s.testWriteString(""); + const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; + try testing.expectEqual(2, constraintWidth(p1)); + s.reset(); + } +} diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 0a5c8e7b0..7be4d7c12 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -14,7 +14,6 @@ const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); const pagepkg = @import("page.zig"); -const cellpkg = @import("../renderer/cell.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const style = @import("style.zig"); @@ -9098,97 +9097,3 @@ test "Screen UTF8 cell map with blank prefix" { .y = 1, }, cell_map.items[3]); } - -test "Screen cell constraint widths" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, 4, 1, 0); - defer s.deinit(); - - // for each case, the numbers in the comment denote expected - // constraint widths for the symbol-containing cells - - // symbol->nothing: 2 - { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, cellpkg.constraintWidth(p0)); - s.reset(); - } - - // symbol->character: 1 - { - try s.testWriteString("z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, cellpkg.constraintWidth(p0)); - s.reset(); - } - - // symbol->space: 2 - { - try s.testWriteString(" z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(2, cellpkg.constraintWidth(p0)); - s.reset(); - } - // symbol->no-break space: 1 - { - try s.testWriteString("\u{00a0}z"); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, cellpkg.constraintWidth(p0)); - s.reset(); - } - - // symbol->end of row: 1 - { - try s.testWriteString(" "); - const p3 = s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?; - try testing.expectEqual(1, cellpkg.constraintWidth(p3)); - s.reset(); - } - - // character->symbol: 2 - { - try s.testWriteString("z"); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, cellpkg.constraintWidth(p1)); - s.reset(); - } - - // symbol->symbol: 1,1 - { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(1, cellpkg.constraintWidth(p0)); - try testing.expectEqual(1, cellpkg.constraintWidth(p1)); - s.reset(); - } - - // symbol->space->symbol: 2,2 - { - try s.testWriteString(" "); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - const p2 = s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?; - try testing.expectEqual(2, cellpkg.constraintWidth(p0)); - try testing.expectEqual(2, cellpkg.constraintWidth(p2)); - s.reset(); - } - - // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) - { - try s.testWriteString(""); - const p0 = s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(1, cellpkg.constraintWidth(p0)); - s.reset(); - } - - // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) - { - try s.testWriteString(""); - const p1 = s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?; - try testing.expectEqual(2, cellpkg.constraintWidth(p1)); - s.reset(); - } -} From bdf07727ad91477b8d129b6e5d0498d4ec2b5d9b Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 29 Sep 2025 19:06:11 -0500 Subject: [PATCH 52/52] gtk: some bell features need to happen on receipt of every BEL Some bell features should be triggered on the receipt of every BEL character, namely `audio` and `system`. However, Ghostty was setting a boolean to `true` upon the receipt of the first BEL. Subsequent BEL characters would be ignored until that boolean was reset to `false`, usually by keyboard/mouse activity. This PR fixes the problem by ensuring that the `audio` and `system` features are triggered every time a BEL is received. Other features continue to be triggered only when the `bell-ringing` boolean state changes. Fixes #8957 --- src/apprt/gtk/class/surface.zig | 52 +++++++++++++++++++++++++++----- src/apprt/gtk/ui/1.2/surface.blp | 1 - 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index fb933073c..344bf8f21 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -51,6 +51,13 @@ pub const Surface = extern struct { pub const Tree = datastruct.SplitTree(Self); pub const properties = struct { + /// This property is set to true when the bell is ringing. Note that + /// this property will only emit a changed signal when there is a + /// full state change. If a bell is ringing and another bell event + /// comes through, the change notification will NOT be emitted. + /// + /// If you need to know every scenario the bell is triggered, + /// listen to the `bell` signal instead. pub const @"bell-ringing" = struct { pub const name = "bell-ringing"; const impl = gobject.ext.defineProperty( @@ -296,6 +303,19 @@ pub const Surface = extern struct { }; pub const signals = struct { + /// Emitted whenever the bell event is received. Unlike the + /// `bell-ringing` property, this is emitted every time the event + /// is received and not just on state changes. + pub const bell = struct { + pub const name = "bell"; + pub const connect = impl.connect; + const impl = gobject.ext.defineSignal( + name, + Self, + &.{}, + void, + ); + }; /// Emitted whenever the surface would like to be closed for any /// reason. /// @@ -1674,6 +1694,16 @@ pub const Surface = extern struct { } pub fn setBellRinging(self: *Self, ringing: bool) void { + // Prevent duplicate change notifications if the signals we emit + // in this function cause this state to change again. + self.as(gobject.Object).freezeNotify(); + defer self.as(gobject.Object).thawNotify(); + + // Logic around bell reaction happens on every event even if we're + // already in the ringing state. + if (ringing) self.ringBell(); + + // Property change only happens on actual state change const priv = self.private(); if (priv.bell_ringing == ringing) return; priv.bell_ringing = ringing; @@ -1858,20 +1888,26 @@ pub const Surface = extern struct { self.as(gtk.Widget).setCursorFromName(name.ptr); } - fn propBellRinging( - self: *Self, - _: *gobject.ParamSpec, - _: ?*anyopaque, - ) callconv(.c) void { + /// Handle bell features that need to happen every time a BEL is received + /// Currently this is audio and system but this could change in the future. + fn ringBell(self: *Self) void { const priv = self.private(); - if (!priv.bell_ringing) return; + + // Emit the signal + signals.bell.impl.emit( + self, + null, + .{}, + null, + ); // Activate actions if they exist _ = self.as(gtk.Widget).activateAction("tab.ring-bell", null); _ = self.as(gtk.Widget).activateAction("win.ring-bell", null); - // Do our sound const config = if (priv.config) |c| c.get() else return; + + // Do our sound if (config.@"bell-features".audio) audio: { const config_path = config.@"bell-audio-path" orelse break :audio; const path, const required = switch (config_path) { @@ -2859,7 +2895,6 @@ pub const Surface = extern struct { class.bindTemplateCallback("notify_mouse_hover_url", &propMouseHoverUrl); class.bindTemplateCallback("notify_mouse_hidden", &propMouseHidden); class.bindTemplateCallback("notify_mouse_shape", &propMouseShape); - class.bindTemplateCallback("notify_bell_ringing", &propBellRinging); class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown); class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown); @@ -2884,6 +2919,7 @@ pub const Surface = extern struct { }); // Signals + signals.bell.impl.register(.{}); signals.@"close-request".impl.register(.{}); signals.@"clipboard-read".impl.register(.{}); signals.@"clipboard-write".impl.register(.{}); diff --git a/src/apprt/gtk/ui/1.2/surface.blp b/src/apprt/gtk/ui/1.2/surface.blp index ad971e991..7ed78ecb3 100644 --- a/src/apprt/gtk/ui/1.2/surface.blp +++ b/src/apprt/gtk/ui/1.2/surface.blp @@ -169,7 +169,6 @@ template $GhosttySurface: Adw.Bin { "surface", ] - notify::bell-ringing => $notify_bell_ringing(); notify::config => $notify_config(); notify::error => $notify_error(); notify::mouse-hover-url => $notify_mouse_hover_url();