ghostty/src/font/Metrics.zig
Qwerasd c47459b4a2 font: add icon height to nerd font constraints
Icons were often WAY too big before because they were filling the whole
cell in height, which isn't great lol. This commit adds an `icon_height`
metric which is used to constrain glyphs that shouldn't be the size of
the entire cell.
2025-07-07 10:04:11 -06:00

571 lines
20 KiB
Zig

const Metrics = @This();
const std = @import("std");
/// Recommended cell width and height for a monospace grid using this font.
cell_width: u32,
cell_height: u32,
/// Distance in pixels from the bottom of the cell to the text baseline.
cell_baseline: u32,
/// Distance in pixels from the top of the cell to the top of the underline.
underline_position: u32,
/// Thickness in pixels of the underline.
underline_thickness: u32,
/// Distance in pixels from the top of the cell to the top of the strikethrough.
strikethrough_position: u32,
/// Thickness in pixels of the strikethrough.
strikethrough_thickness: u32,
/// Distance in pixels from the top of the cell to the top of the overline.
/// Can be negative to adjust the position above the top of the cell.
overline_position: i32,
/// Thickness in pixels of the overline.
overline_thickness: u32,
/// Thickness in pixels of box drawing characters.
box_thickness: u32,
/// The thickness in pixels of the cursor sprite. This has a default value
/// because it is not determined by fonts but rather by user configuration.
cursor_thickness: u32 = 1,
/// The height in pixels of the cursor sprite.
cursor_height: u32,
/// The constraint height for nerd fonts icons.
icon_height: u32,
/// Minimum acceptable values for some fields to prevent modifiers
/// from being able to, for example, cause 0-thickness underlines.
const Minimums = struct {
const cell_width = 1;
const cell_height = 1;
const underline_thickness = 1;
const strikethrough_thickness = 1;
const overline_thickness = 1;
const box_thickness = 1;
const cursor_thickness = 1;
const cursor_height = 1;
const icon_height = 1;
};
/// Metrics extracted from a font face, based on
/// the metadata tables and glyph measurements.
pub const FaceMetrics = struct {
/// The minimum cell width that can contain any glyph in the ASCII range.
///
/// Determined by measuring all printable glyphs in the ASCII range.
cell_width: f64,
/// The typographic ascent metric from the font.
/// This represents the maximum vertical position of the highest ascender.
///
/// Relative to the baseline, in px, +Y=up
ascent: f64,
/// The typographic descent metric from the font.
/// This represents the minimum vertical position of the lowest descender.
///
/// Relative to the baseline, in px, +Y=up
///
/// Note:
/// As this value is generally below the baseline, it is typically negative.
descent: f64,
/// The typographic line gap (aka "leading") metric from the font.
/// This represents the additional space to be added between lines in
/// addition to the space defined by the ascent and descent metrics.
///
/// Positive value in px
line_gap: f64,
/// The TOP of the underline stroke.
///
/// Relative to the baseline, in px, +Y=up
underline_position: ?f64 = null,
/// The thickness of the underline stroke in px.
underline_thickness: ?f64 = null,
/// The TOP of the strikethrough stroke.
///
/// Relative to the baseline, in px, +Y=up
strikethrough_position: ?f64 = null,
/// The thickness of the strikethrough stroke in px.
strikethrough_thickness: ?f64 = null,
/// The height of capital letters in the font, either derived from
/// a provided cap height metric or measured from the height of the
/// capital H glyph.
cap_height: ?f64 = null,
/// The height of lowercase letters in the font, either derived from
/// a provided ex height metric or measured from the height of the
/// lowercase x glyph.
ex_height: ?f64 = null,
/// The width of the character "水" (CJK water ideograph, U+6C34),
/// if present. This is used for font size adjustment, to normalize
/// the width of CJK fonts mixed with latin fonts.
///
/// NOTE: IC = Ideograph Character
ic_width: ?f64 = null,
/// Convenience function for getting the line height
/// (ascent - descent + line_gap).
pub inline fn lineHeight(self: FaceMetrics) f64 {
return self.ascent - self.descent + self.line_gap;
}
};
/// Calculate our metrics based on values extracted from a font.
///
/// Try to pass values with as much precision as possible,
/// do not round them before using them for this function.
///
/// For any nullable options that are not provided, estimates will be used.
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());
// 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
// bumps up against either edge of the cell vertically.
const half_line_gap = face.line_gap / 2;
// Unlike all our other metrics, `cell_baseline` is relative to the
// BOTTOM of the cell.
const cell_baseline = @round(half_line_gap - face.descent);
// We calculate a top_to_baseline to make following calculations simpler.
const top_to_baseline = cell_height - cell_baseline;
// If we don't have a provided cap height,
// we estimate it as 75% of the ascent.
const cap_height = face.cap_height orelse face.ascent * 0.75;
// If we don't have a provided ex height,
// we estimate it as 75% of the cap height.
const ex_height = face.ex_height orelse cap_height * 0.75;
// If we don't have a provided underline thickness,
// we estimate it as 15% of the ex height.
const underline_thickness = @max(1, @ceil(face.underline_thickness orelse 0.15 * ex_height));
// If we don't have a provided strikethrough thickness
// then we just use the underline thickness for it.
const strikethrough_thickness = @max(1, @ceil(face.strikethrough_thickness orelse underline_thickness));
// If we don't have a provided underline position then
// we place it 1 underline-thickness below the baseline.
const underline_position = @round(top_to_baseline -
(face.underline_position orelse
-underline_thickness));
// If we don't have a provided strikethrough position
// then we center the strikethrough stroke at half the
// ex height, so that it's perfectly centered on lower
// case text.
const strikethrough_position = @round(top_to_baseline -
(face.strikethrough_position orelse
ex_height * 0.5 + strikethrough_thickness * 0.5));
// 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);
var result: Metrics = .{
.cell_width = @intFromFloat(cell_width),
.cell_height = @intFromFloat(cell_height),
.cell_baseline = @intFromFloat(cell_baseline),
.underline_position = @intFromFloat(underline_position),
.underline_thickness = @intFromFloat(underline_thickness),
.strikethrough_position = @intFromFloat(strikethrough_position),
.strikethrough_thickness = @intFromFloat(strikethrough_thickness),
.overline_position = 0,
.overline_thickness = @intFromFloat(underline_thickness),
.box_thickness = @intFromFloat(underline_thickness),
.cursor_height = @intFromFloat(cell_height),
.icon_height = @intFromFloat(icon_height),
};
// Ensure all metrics are within their allowable range.
result.clamp();
// std.log.debug("metrics={}", .{result});
return result;
}
/// Apply a set of modifiers.
pub fn apply(self: *Metrics, mods: ModifierSet) void {
var it = mods.iterator();
while (it.next()) |entry| {
switch (entry.key_ptr.*) {
// We clamp these values to a minimum of 1 to prevent divide-by-zero
// in downstream operations.
inline .cell_width,
.cell_height,
=> |tag| {
// Compute the new value. If it is the same avoid the work.
const original = @field(self, @tagName(tag));
const new = @max(entry.value_ptr.apply(original), 1);
if (new == original) continue;
// Set the new value
@field(self, @tagName(tag)) = new;
// For cell height, we have to also modify some positions
// that are absolute from the top of the cell. The main goal
// here is to center the baseline so that text is vertically
// centered in the cell.
if (comptime tag == .cell_height) {
// We split the difference in half because we want to
// center the baseline in the cell.
if (new > original) {
const diff = (new - original) / 2;
self.cell_baseline +|= diff;
self.underline_position +|= diff;
self.strikethrough_position +|= diff;
} else {
const diff = (original - new) / 2;
self.cell_baseline -|= diff;
self.underline_position -|= diff;
self.strikethrough_position -|= diff;
}
}
},
inline else => |tag| {
@field(self, @tagName(tag)) = entry.value_ptr.apply(@field(self, @tagName(tag)));
},
}
}
// Prevent modifiers from pushing metrics out of their allowable range.
self.clamp();
}
/// Clamp all metrics to their allowable range.
fn clamp(self: *Metrics) void {
inline for (std.meta.fields(Metrics)) |field| {
if (@hasDecl(Minimums, field.name)) {
@field(self, field.name) = @max(
@field(self, field.name),
@field(Minimums, field.name),
);
}
}
}
/// A set of modifiers to apply to metrics. We use a hash map because
/// we expect most metrics to be unmodified and want to take up as
/// little space as possible.
pub const ModifierSet = std.AutoHashMapUnmanaged(Key, Modifier);
/// A modifier to apply to a metrics value. The modifier value represents
/// a delta, so percent is a percentage to change, not a percentage of.
/// For example, "20%" is 20% larger, not 20% of the value. Likewise,
/// an absolute value of "20" is 20 larger, not literally 20.
pub const Modifier = union(enum) {
percent: f64,
absolute: i32,
/// Parses the modifier value. If the value ends in "%" it is assumed
/// to be a percent, otherwise the value is parsed as an integer.
pub fn parse(input: []const u8) !Modifier {
if (input.len == 0) return error.InvalidFormat;
if (input[input.len - 1] == '%') {
var percent = std.fmt.parseFloat(
f64,
input[0 .. input.len - 1],
) catch return error.InvalidFormat;
percent /= 100;
if (percent <= -1) return .{ .percent = 0 };
if (percent < 0) return .{ .percent = 1 + percent };
return .{ .percent = 1 + percent };
}
return .{
.absolute = std.fmt.parseInt(i32, input, 10) catch
return error.InvalidFormat,
};
}
/// So it works with the config framework.
pub fn parseCLI(input: ?[]const u8) !Modifier {
return try parse(input orelse return error.ValueRequired);
}
/// Used by config formatter
pub fn formatEntry(self: Modifier, formatter: anytype) !void {
var buf: [1024]u8 = undefined;
switch (self) {
.percent => |v| {
try formatter.formatEntry(
[]const u8,
std.fmt.bufPrint(
&buf,
"{d}%",
.{(v - 1) * 100},
) catch return error.OutOfMemory,
);
},
.absolute => |v| {
try formatter.formatEntry(
[]const u8,
std.fmt.bufPrint(
&buf,
"{d}",
.{v},
) catch return error.OutOfMemory,
);
},
}
}
/// 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;
},
.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;
},
};
}
/// Hash using the hasher.
pub fn hash(self: Modifier, hasher: anytype) void {
const autoHash = std.hash.autoHash;
autoHash(hasher, std.meta.activeTag(self));
switch (self) {
// floats can't be hashed directly so we bitcast to i64.
// for the purpose of what we're trying to do this seems
// good enough but I would prefer value hashing.
.percent => |v| autoHash(hasher, @as(i64, @bitCast(v))),
.absolute => |v| autoHash(hasher, v),
}
}
test "formatConfig percent" {
const configpkg = @import("../config.zig");
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
defer buf.deinit();
const p = try parseCLI("24%");
try p.formatEntry(configpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = 24%\n", buf.items);
}
test "formatConfig absolute" {
const configpkg = @import("../config.zig");
const testing = std.testing;
var buf = std.ArrayList(u8).init(testing.allocator);
defer buf.deinit();
const p = try parseCLI("-30");
try p.formatEntry(configpkg.entryFormatter("a", buf.writer()));
try std.testing.expectEqualSlices(u8, "a = -30\n", buf.items);
}
};
/// Key is an enum of all the available metrics keys.
pub const Key = key: {
const field_infos = std.meta.fields(Metrics);
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;
enumFields[i] = .{ .name = field.name, .value = i };
count += 1;
}
var decls = [_]std.builtin.Type.Declaration{};
break :key @Type(.{
.@"enum" = .{
.tag_type = std.math.IntFittingRange(0, count - 1),
.fields = enumFields[0..count],
.decls = &decls,
.is_exhaustive = true,
},
});
};
// NOTE: This is purposely not pub because we want to force outside callers
// to use the `.{}` syntax so unused fields are detected by the compiler.
fn init() Metrics {
return .{
.cell_width = 0,
.cell_height = 0,
.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,
};
}
test "Metrics: apply modifiers" {
const testing = std.testing;
const alloc = testing.allocator;
var set: ModifierSet = .{};
defer set.deinit(alloc);
try set.put(alloc, .cell_width, .{ .percent = 1.2 });
var m: Metrics = init();
m.cell_width = 100;
m.apply(set);
try testing.expectEqual(@as(u32, 120), m.cell_width);
}
test "Metrics: adjust cell height smaller" {
const testing = std.testing;
const alloc = testing.allocator;
var set: ModifierSet = .{};
defer set.deinit(alloc);
try set.put(alloc, .cell_height, .{ .percent = 0.5 });
var m: Metrics = init();
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
m.cell_height = 100;
m.cursor_height = 100;
m.apply(set);
try testing.expectEqual(@as(u32, 50), m.cell_height);
try testing.expectEqual(@as(u32, 25), m.cell_baseline);
try testing.expectEqual(@as(u32, 30), m.underline_position);
try testing.expectEqual(@as(u32, 5), m.strikethrough_position);
// Cursor height is separate from cell height and does not follow it.
try testing.expectEqual(@as(u32, 100), m.cursor_height);
}
test "Metrics: adjust cell height larger" {
const testing = std.testing;
const alloc = testing.allocator;
var set: ModifierSet = .{};
defer set.deinit(alloc);
try set.put(alloc, .cell_height, .{ .percent = 2 });
var m: Metrics = init();
m.cell_baseline = 50;
m.underline_position = 55;
m.strikethrough_position = 30;
m.cell_height = 100;
m.cursor_height = 100;
m.apply(set);
try testing.expectEqual(@as(u32, 200), m.cell_height);
try testing.expectEqual(@as(u32, 100), m.cell_baseline);
try testing.expectEqual(@as(u32, 105), m.underline_position);
try testing.expectEqual(@as(u32, 80), m.strikethrough_position);
// Cursor height is separate from cell height and does not follow it.
try testing.expectEqual(@as(u32, 100), m.cursor_height);
}
test "Modifier: parse absolute" {
const testing = std.testing;
{
const m = try Modifier.parse("100");
try testing.expectEqual(Modifier{ .absolute = 100 }, m);
}
{
const m = try Modifier.parse("-100");
try testing.expectEqual(Modifier{ .absolute = -100 }, m);
}
}
test "Modifier: parse percent" {
const testing = std.testing;
{
const m = try Modifier.parse("20%");
try testing.expectEqual(Modifier{ .percent = 1.2 }, m);
}
{
const m = try Modifier.parse("-20%");
try testing.expectEqual(Modifier{ .percent = 0.8 }, m);
}
{
const m = try Modifier.parse("0%");
try testing.expectEqual(Modifier{ .percent = 1 }, m);
}
}
test "Modifier: percent" {
const testing = std.testing;
{
const m: Modifier = .{ .percent = 0.8 };
const v: u32 = m.apply(@as(u32, 100));
try testing.expectEqual(@as(u32, 80), v);
}
{
const m: Modifier = .{ .percent = 1.8 };
const v: u32 = m.apply(@as(u32, 100));
try testing.expectEqual(@as(u32, 180), v);
}
}
test "Modifier: absolute" {
const testing = std.testing;
{
const m: Modifier = .{ .absolute = -100 };
const v: u32 = m.apply(@as(u32, 100));
try testing.expectEqual(@as(u32, 0), v);
}
{
const m: Modifier = .{ .absolute = -120 };
const v: u32 = m.apply(@as(u32, 100));
try testing.expectEqual(@as(u32, 0), v);
}
{
const m: Modifier = .{ .absolute = 100 };
const v: u32 = m.apply(@as(u32, 100));
try testing.expectEqual(@as(u32, 200), v);
}
}