mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
554 lines
19 KiB
Zig
554 lines
19 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,
|
|
|
|
/// 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;
|
|
};
|
|
|
|
/// 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.ascent - face.descent + face.line_gap);
|
|
|
|
// 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));
|
|
|
|
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),
|
|
};
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|