4 Commits

Author SHA1 Message Date
Mitchell Hashimoto
7071a22cb5 v1.2.2 2025-10-08 10:02:24 -07:00
Mitchell Hashimoto
a586b47dc9 Implement and use generic approx equality tester (#8979)
Seems like there needs to be a general, easy-to-use solution for
approximate equality testing of containers holding floats (see, e.g.,
https://github.com/ghostty-org/ghostty/pull/8563#pullrequestreview-3281357931).
How's this?
2025-10-08 09:59:22 -07:00
Mitchell Hashimoto
c8efb2a8c9 font: Add comprehensive constraint tests (#9023)
As promised in #8990.

I opted for hardcoded metrics and bounding boxes rather than actually
loading fonts and glyphs, both to avoid backend dependence and limit the
focus to the constraint calculations themselves, and because I wanted to
test a case that isn't exhibited by any of the fonts available in the
repo.

This also fixes an error from #8990, probably due to a botched
cherry-pick or rebase.
2025-10-08 09:59:12 -07:00
Mitchell Hashimoto
62ed472d9e Revert "renderer: slightly optimize screen copy" (#8974)
This reverts commit fcea09e413 because it
appears to be causing memory leaks.
2025-10-08 09:57:14 -07:00
10 changed files with 365 additions and 109 deletions

View File

@@ -1,6 +1,6 @@
.{ .{
.name = .ghostty, .name = .ghostty,
.version = "1.2.1", .version = "1.2.2",
.paths = .{""}, .paths = .{""},
.fingerprint = 0x64407a2a0b4147e5, .fingerprint = 0x64407a2a0b4147e5,
.minimum_zig_version = "0.14.1", .minimum_zig_version = "0.14.1",

View File

@@ -52,8 +52,8 @@
<releases> <releases>
<!-- TODO: Generate this automatically --> <!-- TODO: Generate this automatically -->
<release version="1.2.1" date="2025-10-06"> <release version="1.2.2" date="2025-10-08">
<url type="details">https://ghostty.org/docs/install/release-notes/1-2-1</url> <url type="details">https://ghostty.org/docs/install/release-notes/1-2-2</url>
</release> </release>
</releases> </releases>
</component> </component>

View File

@@ -40,7 +40,7 @@
in in
stdenv.mkDerivation (finalAttrs: { stdenv.mkDerivation (finalAttrs: {
pname = "ghostty"; pname = "ghostty";
version = "1.2.1"; version = "1.2.2";
# We limit source like this to try and reduce the amount of rebuilds as possible # We limit source like this to try and reduce the amount of rebuilds as possible
# thus we only provide the source that is needed for the build # thus we only provide the source that is needed for the build

View File

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

View File

@@ -0,0 +1,147 @@
// The contents of this file is largely based on testing.zig from the Zig 0.15.1
// stdlib, distributed under the MIT license, copyright (c) Zig contributors
const std = @import("std");
/// Generic, recursive equality testing utility using approximate comparison for
/// floats and equality for everything else
///
/// Based on `std.testing.expectEqual` and `std.testing.expectEqualSlices`.
///
/// The relative tolerance is currently hardcoded to `sqrt(eps(float_type))`.
pub inline fn expectApproxEqual(expected: anytype, actual: anytype) !void {
const T = @TypeOf(expected, actual);
return expectApproxEqualInner(T, expected, actual);
}
fn expectApproxEqualInner(comptime T: type, expected: T, actual: T) !void {
switch (@typeInfo(T)) {
// check approximate equality for floats
.float => {
const sqrt_eps = comptime std.math.sqrt(std.math.floatEps(T));
if (!std.math.approxEqRel(T, expected, actual, sqrt_eps)) {
print("expected approximately {any}, found {any}\n", .{ expected, actual });
return error.TestExpectedApproxEqual;
}
},
// recurse into containers
.array => {
const diff_index: usize = diff_index: {
const shortest = @min(expected.len, actual.len);
var index: usize = 0;
while (index < shortest) : (index += 1) {
expectApproxEqual(actual[index], expected[index]) catch break :diff_index index;
}
break :diff_index if (expected.len == actual.len) return else shortest;
};
print("slices not approximately equal. first significant difference occurs at index {d} (0x{X})\n", .{ diff_index, diff_index });
return error.TestExpectedApproxEqual;
},
.vector => |info| {
var i: usize = 0;
while (i < info.len) : (i += 1) {
expectApproxEqual(expected[i], actual[i]) catch {
print("index {d} incorrect. expected approximately {any}, found {any}\n", .{
i, expected[i], actual[i],
});
return error.TestExpectedApproxEqual;
};
}
},
.@"struct" => |structType| {
inline for (structType.fields) |field| {
try expectApproxEqual(@field(expected, field.name), @field(actual, field.name));
}
},
// unwrap unions, optionals, and error unions
.@"union" => |union_info| {
if (union_info.tag_type == null) {
// untagged unions can only be compared bitwise,
// so expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
}
const Tag = std.meta.Tag(@TypeOf(expected));
const expectedTag = @as(Tag, expected);
const actualTag = @as(Tag, actual);
std.testing.expectEqual(expectedTag, actualTag) catch {
return error.TestExpectedApproxEqual;
};
// we only reach this switch if the tags are equal
switch (expected) {
inline else => |val, tag| try expectApproxEqual(val, @field(actual, @tagName(tag))),
}
},
.optional, .error_union => {
if (expected) |expected_payload| if (actual) |actual_payload| {
return expectApproxEqual(expected_payload, actual_payload);
};
// we only reach this point if there's at least one null or error,
// in which case expectEqual is all we need
std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
};
},
// fall back to expectEqual for everything else
else => std.testing.expectEqual(expected, actual) catch {
return error.TestExpectedApproxEqual;
},
}
}
/// Copy of std.testing.print (not public)
fn print(comptime fmt: []const u8, args: anytype) void {
if (@inComptime()) {
@compileError(std.fmt.comptimePrint(fmt, args));
} else if (std.testing.backend_can_print) {
std.debug.print(fmt, args);
}
}
// Tests based on the `expectEqual` tests in the Zig stdlib
test "expectApproxEqual.union(enum)" {
const T = union(enum) {
a: i32,
b: f32,
};
const b10 = T{ .b = 10.0 };
const b10plus = T{ .b = 10.000001 };
try expectApproxEqual(b10, b10plus);
}
test "expectApproxEqual nested array" {
const a = [2][2]f32{
[_]f32{ 1.0, 0.0 },
[_]f32{ 0.0, 1.0 },
};
const b = [2][2]f32{
[_]f32{ 1.000001, 0.0 },
[_]f32{ 0.0, 0.999999 },
};
try expectApproxEqual(a, b);
}
test "expectApproxEqual vector" {
const a: @Vector(4, f32) = @splat(4.0);
const b: @Vector(4, f32) = @splat(4.000001);
try expectApproxEqual(a, b);
}
test "expectApproxEqual struct" {
const a = .{ 1, @as(f32, 1.0) };
const b = .{ 1, @as(f32, 0.999999) };
try expectApproxEqual(a, b);
}

View File

@@ -19,6 +19,7 @@ const std = @import("std");
const assert = std.debug.assert; const assert = std.debug.assert;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const config = @import("../config.zig"); const config = @import("../config.zig");
const comparison = @import("../datastruct/comparison.zig");
const font = @import("main.zig"); const font = @import("main.zig");
const options = font.options; const options = font.options;
const DeferredFace = font.DeferredFace; const DeferredFace = font.DeferredFace;
@@ -1199,7 +1200,7 @@ test "metrics" {
try c.updateMetrics(); try c.updateMetrics();
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 8, .cell_width = 8,
// The cell height is 17 px because the calculation is // The cell height is 17 px because the calculation is
// //
@@ -1229,12 +1230,12 @@ test "metrics" {
.icon_height = 12.24, .icon_height = 12.24,
.face_width = 8.0, .face_width = 8.0,
.face_height = 16.784, .face_height = 16.784,
.face_y = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value .face_y = -0.04,
}, c.metrics); }, c.metrics);
// Resize should change metrics // Resize should change metrics
try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 }); try c.setSize(.{ .points = 24, .xdpi = 96, .ydpi = 96 });
try std.testing.expectEqual(font.Metrics{ try comparison.expectApproxEqual(font.Metrics{
.cell_width = 16, .cell_width = 16,
.cell_height = 34, .cell_height = 34,
.cell_baseline = 6, .cell_baseline = 6,
@@ -1249,7 +1250,7 @@ test "metrics" {
.icon_height = 24.48, .icon_height = 24.48,
.face_width = 16.0, .face_width = 16.0,
.face_height = 33.568, .face_height = 33.568,
.face_y = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value .face_y = -0.08,
}, c.metrics); }, c.metrics);
} }
@@ -1493,29 +1494,7 @@ test "face metrics" {
.{ narrowMetricsExpected, wideMetricsExpected }, .{ narrowMetricsExpected, wideMetricsExpected },
.{ narrowMetrics, wideMetrics }, .{ narrowMetrics, wideMetrics },
) |metricsExpected, metricsActual| { ) |metricsExpected, metricsActual| {
inline for (@typeInfo(font.Metrics.FaceMetrics).@"struct".fields) |field| { try comparison.expectApproxEqual(metricsExpected, metricsActual);
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 => {
if (expected) |expectedValue| if (actual) |actualValue| {
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 // Verify estimated metrics. icWidth() should equal the smaller of

View File

@@ -507,3 +507,196 @@ test "Variation.Id: slnt should be 1936486004" {
try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id)));
try testing.expectEqualStrings("slnt", &(id.str())); try testing.expectEqualStrings("slnt", &(id.str()));
} }
test "Constraints" {
const comparison = @import("../datastruct/comparison.zig");
const getConstraint = @import("nerd_font_attributes.zig").getConstraint;
// Hardcoded data matches metrics from CoreText at size 12 and DPI 96.
// Define grid metrics (matches font-family = JetBrains Mono)
const metrics: Metrics = .{
.cell_width = 10,
.cell_height = 22,
.cell_baseline = 5,
.underline_position = 19,
.underline_thickness = 1,
.strikethrough_position = 12,
.strikethrough_thickness = 1,
.overline_position = 0,
.overline_thickness = 1,
.box_thickness = 1,
.cursor_thickness = 1,
.cursor_height = 22,
.icon_height = 44.48 / 3.0,
.face_width = 9.6,
.face_height = 21.12,
.face_y = 0.2,
};
// ASCII (no constraint).
{
const constraint: RenderOptions.Constraint = .none;
// BBox of 'x' from JetBrains Mono.
const glyph_x: GlyphSize = .{
.width = 6.784,
.height = 15.28,
.x = 1.408,
.y = 4.84,
};
// Any constraint width: do nothing.
inline for (.{ 1, 2 }) |constraint_width| {
try comparison.expectApproxEqual(
glyph_x,
constraint.constrain(glyph_x, metrics, constraint_width),
);
}
}
// Symbol (same constraint as hardcoded in Renderer.addGlyph).
{
const constraint: RenderOptions.Constraint = .{ .size = .fit };
// BBox of '■' (0x25A0 black square) from Iosevka.
// NOTE: This glyph is designed to span two cells.
const glyph_25A0: GlyphSize = .{
.width = 10.272,
.height = 10.272,
.x = 2.864,
.y = 5.304,
};
// Constraint width 1: scale down and shift to fit a single cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = metrics.face_width,
.height = metrics.face_width,
.x = 0,
.y = 5.64,
},
constraint.constrain(glyph_25A0, metrics, 1),
);
// Constraint width 2: do nothing.
try comparison.expectApproxEqual(
glyph_25A0,
constraint.constrain(glyph_25A0, metrics, 2),
);
}
// Emoji (same constraint as hardcoded in SharedGrid.renderGlyph).
{
const constraint: RenderOptions.Constraint = .{
.size = .cover,
.align_horizontal = .center,
.align_vertical = .center,
.pad_left = 0.025,
.pad_right = 0.025,
};
// BBox of '🥸' (0x1F978) from Apple Color Emoji.
const glyph_1F978: GlyphSize = .{
.width = 20,
.height = 20,
.x = 0.46,
.y = 1,
};
// Constraint width 2: scale to cover two cells with padding, center;
try comparison.expectApproxEqual(
GlyphSize{
.width = 18.72,
.height = 18.72,
.x = 0.44,
.y = 1.4,
},
constraint.constrain(glyph_1F978, metrics, 2),
);
}
// Nerd Font default.
{
const constraint = getConstraint(0xea61).?;
// Verify that this is the constraint we expect.
try std.testing.expectEqual(.fit_cover1, constraint.size);
try std.testing.expectEqual(.icon, constraint.height);
try std.testing.expectEqual(.center1, constraint.align_horizontal);
try std.testing.expectEqual(.center1, constraint.align_vertical);
// BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only.
// NOTE: This icon is part of a group, so the
// constraint applies to a larger bounding box.
const glyph_EA61: GlyphSize = .{
.width = 9.015625,
.height = 13.015625,
.x = 3.015625,
.y = 3.76525,
};
// Constraint width 1: scale and shift group to fit a single cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = 7.2125,
.height = 10.4125,
.x = 0.8125,
.y = 5.950695224719102,
},
constraint.constrain(glyph_EA61, metrics, 1),
);
// Constraint width 2: no scaling; left-align and vertically center group.
try comparison.expectApproxEqual(
GlyphSize{
.width = glyph_EA61.width,
.height = glyph_EA61.height,
.x = 1.015625,
.y = 4.7483690308988775,
},
constraint.constrain(glyph_EA61, metrics, 2),
);
}
// Nerd Font stretch.
{
const constraint = getConstraint(0xe0c0).?;
// Verify that this is the constraint we expect.
try std.testing.expectEqual(.stretch, constraint.size);
try std.testing.expectEqual(.cell, constraint.height);
try std.testing.expectEqual(.start, constraint.align_horizontal);
try std.testing.expectEqual(.center1, constraint.align_vertical);
// BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only.
const glyph_E0C0: GlyphSize = .{
.width = 16.796875,
.height = 16.46875,
.x = -0.796875,
.y = 1.7109375,
};
// Constraint width 1: stretch and position to exactly cover one cell.
try comparison.expectApproxEqual(
GlyphSize{
.width = @floatFromInt(metrics.cell_width),
.height = @floatFromInt(metrics.cell_height),
.x = 0,
.y = 0,
},
constraint.constrain(glyph_E0C0, metrics, 1),
);
// Constraint width 1: stretch and position to exactly cover two cells.
try comparison.expectApproxEqual(
GlyphSize{
.width = @floatFromInt(2 * metrics.cell_width),
.height = @floatFromInt(metrics.cell_height),
.x = 0,
.y = 0,
},
constraint.constrain(glyph_E0C0, metrics, 2),
);
}
}

View File

@@ -363,7 +363,11 @@ pub const Face = struct {
// We center all glyphs within the pixel-rounded and adjusted // We center all glyphs within the pixel-rounded and adjusted
// cell width if it's larger than the face width, so that they // cell width if it's larger than the face width, so that they
// aren't weirdly off to the left. // aren't weirdly off to the left.
if (metrics.face_width < cell_width) { //
// We don't do this if the glyph has a stretch constraint,
// since in that case the position was already calculated with the
// new cell width in mind.
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
// We add half the difference to re-center. // We add half the difference to re-center.
x += (cell_width - metrics.face_width) / 2; x += (cell_width - metrics.face_width) / 2;
} }
@@ -378,18 +382,6 @@ pub const Face = struct {
y = @round(y); y = @round(y);
} }
// 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 glyph has a stretch constraint,
// since in that case the position was already calculated with the
// new cell width in mind.
if ((constraint.size != .stretch) and (metrics.face_width < cell_width)) {
// We add half the difference to re-center.
x += (cell_width - metrics.face_width) / 2;
}
// We make an assumption that font smoothing ("thicken") // We make an assumption that font smoothing ("thicken")
// adds no more than 1 extra pixel to any edge. We don't // adds no more than 1 extra pixel to any edge. We don't
// add extra size if it's a sbix color font though, since // add extra size if it's a sbix color font though, since

View File

@@ -1177,43 +1177,6 @@ test "color emoji" {
const glyph_id = ft_font.glyphIndex('🥸').?; const glyph_id = ft_font.glyphIndex('🥸').?;
try testing.expect(ft_font.isColorGlyph(glyph_id)); try testing.expect(ft_font.isColorGlyph(glyph_id));
} }
// resize
// TODO: Comprehensive tests for constraints,
// this is just an adapted legacy test.
{
const glyph = try ft_font.renderGlyph(
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,
.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);
}
} }
test "mono to bgra" { test "mono to bgra" {

View File

@@ -95,9 +95,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Allocator that can be used /// Allocator that can be used
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
/// MemoryPool for PageList pages which we use when cloning the screen.
page_pool: terminal.PageList.MemoryPool,
/// This mutex must be held whenever any state used in `drawFrame` is /// This mutex must be held whenever any state used in `drawFrame` is
/// being modified, and also when it's being accessed in `drawFrame`. /// being modified, and also when it's being accessed in `drawFrame`.
draw_mutex: std.Thread.Mutex = .{}, draw_mutex: std.Thread.Mutex = .{},
@@ -679,19 +676,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
}; };
errdefer if (display_link) |v| v.release(); errdefer if (display_link) |v| v.release();
// We preheat the page pool with 4 pages- this is an arbitrary
// choice based on what seems reasonable for the number of pages
// used by the viewport area.
var page_pool: terminal.PageList.MemoryPool = try .init(
alloc,
std.heap.page_allocator,
4,
);
errdefer page_pool.deinit();
var result: Self = .{ var result: Self = .{
.alloc = alloc, .alloc = alloc,
.page_pool = page_pool,
.config = options.config, .config = options.config,
.surface_mailbox = options.surface_mailbox, .surface_mailbox = options.surface_mailbox,
.grid_metrics = font_critical.metrics, .grid_metrics = font_critical.metrics,
@@ -774,8 +760,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
} }
pub fn deinit(self: *Self) void { pub fn deinit(self: *Self) void {
self.page_pool.deinit();
self.swap_chain.deinit(); self.swap_chain.deinit();
if (DisplayLink != void) { if (DisplayLink != void) {
@@ -1108,13 +1092,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
full_rebuild: bool, full_rebuild: bool,
}; };
// Empty our page pool, but retain capacity.
self.page_pool.reset(.retain_capacity);
var arena: std.heap.ArenaAllocator = .init(self.alloc);
defer arena.deinit();
const alloc = arena.allocator();
// Update all our data as tightly as possible within the mutex. // Update all our data as tightly as possible within the mutex.
var critical: Critical = critical: { var critical: Critical = critical: {
// const start = try std.time.Instant.now(); // const start = try std.time.Instant.now();
@@ -1171,12 +1148,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
// We used to share terminal state, but we've since learned through // We used to share terminal state, but we've since learned through
// analysis that it is faster to copy the terminal state than to // analysis that it is faster to copy the terminal state than to
// hold the lock while rebuilding GPU cells. // hold the lock while rebuilding GPU cells.
const screen_copy = try state.terminal.screen.clonePool( var screen_copy = try state.terminal.screen.clone(
alloc, self.alloc,
&self.page_pool,
.{ .viewport = .{} }, .{ .viewport = .{} },
null, null,
); );
errdefer screen_copy.deinit();
// Whether to draw our cursor or not. // Whether to draw our cursor or not.
const cursor_style = if (state.terminal.flags.password_input) const cursor_style = if (state.terminal.flags.password_input)
@@ -1192,8 +1169,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
const preedit: ?renderer.State.Preedit = preedit: { const preedit: ?renderer.State.Preedit = preedit: {
if (cursor_style == null) break :preedit null; if (cursor_style == null) break :preedit null;
const p = state.preedit orelse break :preedit null; const p = state.preedit orelse break :preedit null;
break :preedit try p.clone(alloc); break :preedit try p.clone(self.alloc);
}; };
errdefer if (preedit) |p| p.deinit(self.alloc);
// If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path.
// We only do this if the Kitty image state is dirty meaning only if // We only do this if the Kitty image state is dirty meaning only if
@@ -1263,6 +1241,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.full_rebuild = full_rebuild, .full_rebuild = full_rebuild,
}; };
}; };
defer {
critical.screen.deinit();
if (critical.preedit) |p| p.deinit(self.alloc);
}
// Build our GPU cells // Build our GPU cells
try self.rebuildCells( try self.rebuildCells(