Skip to content

Commit 25dab0e

Browse files
authored
fix(font): Rewrite constraint code for improved icon scaling/alignment (#8563)
> This PR will probably need a rebase on the final outcome of #8550 and ~#8552~ #8580, but I'm putting it out here so folks can begin taking a look if they want. This is a rewrite of the code that applies scaling and alignment constraints. The main intention is to further improve how Nerd Font icons are matched to the primary font. This PR aligns the calculations more closely with how the Nerd Font `font-patcher` script works, except in two cases where we can easily do something unambiguously better (one because of what's arguably a bug in the script, and one because we do multi-cell alignment with knowledge of the pixel-rounded cell grid). A goal of the rewrite is to make the scaling and alignment calculations as clear and easy to follow as possible. I'll lead with some screenshots. First the status quo, then this PR. <img width="505" height="357" alt="Screenshot 2025-09-07 at 17 23 51" src="https://github.com/user-attachments/assets/8e3ff9fd-3b66-4d54-be38-d54cf3b6cc5b" /><img width="505" height="357" alt="Screenshot 2025-09-07 at 17 20 39" src="https://github.com/user-attachments/assets/84fbe076-2e3f-4879-b9b2-91ce86b9ef5f" /> Relevant specs: macOS; 1920x1080; Ghostty config: ```ini font-family = "CommitMono" font-size = "15" adjust-cell-height = "+20%" ``` **Points to note** * Icons are generally larger, making better use of the available space. * Icons are aligned nearly a pixel lower, better matching the text. This is because alignment is now calculated from face metrics/bearings, not the pixel-rounded cell. (See more below.) * Relative sizes are better matched. Note especially that tall and narrow icons, like the git branch symbol and icons depicting sheets of paper, look conspicuously small in the status quo. With this PR, they're better matched to other icons. * Look at the letter Z icon I use as prompt character for zsh. It's _tiny_ in the status quo, but properly sized with this PR. This demonstrates the most important and clear-cut improvement we make over `font-patcher`. (See more below.) * Icons wider than a single cell are now left-aligned rather than centered across two cells. I think this is preferable and makes better use of space in most relevant contexts. - Consider a Neovim bufferline showing the buffer title as a filetype icon followed by the file name. Padding on the left would be a waste of space, but having that extra space on the right can improve legibility. - In listings, such as in the screenshots, columns look tidier when their left edges are straight rather than ragged. - This is how `font-patcher` does alignment, and thus what Nerd Font users and UI designers expect. **Implementation details** I won't get too deep in the weeds here; see the code and comments. In brief: * `size_horizontal` and `size_vertical` are combined to a single `size`, which can be `.none, .stretch, .fit, .cover` or `.fit_cover1`. The latter implements the `pa` rule from `font-patcher`, except it works better for icons that are small before scaling, like the letter Z prompt in the screenshots. In short, it preserves aspect ratio while clamping the size such that the icon `.cover`s at least one cell and `.fit`s within the available space. See code comments and ryanoasis/nerd-fonts/pull/1926 for details. * An alignment mode `.center1` is added, implementing the centering rule from `font-patcher` that I explained/defended above. In short, we center the icon _in the first cell_, even it's allowed to span multiple cells. For icons wider than a single cell, the lower bound that prevents them from protruding to the left kicks in and turns this into left-alignment. We keep the regular `.center` rule around for use with emojis, et cetera. * Scaling and alignment calculations only use the unrounded face metrics and bearings. This ensures that pixel rounding of the cell and baseline, and `adjust-cell-{width,height}`, don't affect scaling or relative alignment; the icons are always scaled and aligned to the _face_. (The one place we need to use cell metrics in the calculations is when we use `cell_width` to obtain the inter-cell padding needed to correctly center or right-align a glyph across two cells.) - We can do this with impunity because we're blessed with sprite glyphs in place of the "icons" that are actually box drawing and block graphics characters 🙌 **Guide** The meat of the changes is 100 % in `src/font/face.zig` and `src/font/nerd_font_codegen.py`. Changes to other files only amount to a) adding/changing some struct fields to get numbers to where they need to be (see `src/font/Metrics.zig`), and b) collateral updates to make otherwise unchanged code and tests work with/take advantage of the modified structs. Most files should have a clear and friendly diff. The exception is the bottom half of `src/font/face.zig`, where the diff is meaningless and the new code should just be reviewed on its own merits. This is the part where the `constrain` function is rewritten and refactored. Scarred by countless hours perusing `font-patcher`, I tried hard to make the math and logic easy to follow here. I hope I have succeeded 🤞
2 parents f73666a + e3ebdc7 commit 25dab0e

File tree

10 files changed

+812
-821
lines changed

10 files changed

+812
-821
lines changed

src/config/Config.zig

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,12 @@ pub const compatibility = std.StaticStringMap(
416416
/// necessarily force them to be. Decreasing this value will make nerd font
417417
/// icons smaller.
418418
///
419-
/// The default value for the icon height is 1.2 times the height of capital
420-
/// letters in your primary font, so something like -16.6% would make icons
421-
/// roughly the same height as capital letters.
419+
/// This value only applies to icons that are constrained to a single cell by
420+
/// neighboring characters. An icon that is free to spread across two cells
421+
/// can always use up to the full line height of the primary font.
422+
///
423+
/// The default value is 2/3 times the height of capital letters in your primary
424+
/// font plus 1/3 times the font's line height.
422425
///
423426
/// See the notes about adjustments in `adjust-cell-width`.
424427
///

src/font/Collection.zig

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,9 @@ test "metrics" {
12131213
// and 1em should be the point size * dpi scale, so 12 * (96/72)
12141214
// which is 16, and 16 * 1.049 = 16.784, which finally is rounded
12151215
// to 17.
1216+
//
1217+
// The icon height is (2 * cap_height + face_height) / 3
1218+
// = (2 * 623 + 1049) / 3 = 765, and 16 * 0.765 = 12.24.
12161219
.cell_height = 17,
12171220
.cell_baseline = 3,
12181221
.underline_position = 17,
@@ -1223,7 +1226,10 @@ test "metrics" {
12231226
.overline_thickness = 1,
12241227
.box_thickness = 1,
12251228
.cursor_height = 17,
1226-
.icon_height = 11,
1229+
.icon_height = 12.24,
1230+
.face_width = 8.0,
1231+
.face_height = 16.784,
1232+
.face_y = @round(3.04) - @as(f64, 3.04), // use f64, not comptime float, for exact match with runtime value
12271233
}, c.metrics);
12281234

12291235
// Resize should change metrics
@@ -1240,7 +1246,10 @@ test "metrics" {
12401246
.overline_thickness = 2,
12411247
.box_thickness = 2,
12421248
.cursor_height = 34,
1243-
.icon_height = 23,
1249+
.icon_height = 24.48,
1250+
.face_width = 16.0,
1251+
.face_height = 33.568,
1252+
.face_y = @round(6.08) - @as(f64, 6.08), // use f64, not comptime float, for exact match with runtime value
12441253
}, c.metrics);
12451254
}
12461255

src/font/Metrics.zig

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,17 @@ cursor_thickness: u32 = 1,
3636
cursor_height: u32,
3737

3838
/// The constraint height for nerd fonts icons.
39-
icon_height: u32,
39+
icon_height: f64,
4040

41-
/// Original cell width in pixels. This is used to keep
42-
/// glyphs centered if the cell width is adjusted wider.
43-
original_cell_width: ?u32 = null,
41+
/// The unrounded face width, used in scaling calculations.
42+
face_width: f64,
43+
44+
/// The unrounded face height, used in scaling calculations.
45+
face_height: f64,
46+
47+
/// The vertical bearing of face within the pixel-rounded
48+
/// and possibly height-adjusted cell
49+
face_y: f64,
4450

4551
/// Minimum acceptable values for some fields to prevent modifiers
4652
/// from being able to, for example, cause 0-thickness underlines.
@@ -53,7 +59,9 @@ const Minimums = struct {
5359
const box_thickness = 1;
5460
const cursor_thickness = 1;
5561
const cursor_height = 1;
56-
const icon_height = 1;
62+
const icon_height = 1.0;
63+
const face_height = 1.0;
64+
const face_width = 1.0;
5765
};
5866

5967
/// Metrics extracted from a font face, based on
@@ -214,8 +222,10 @@ pub fn calc(face: FaceMetrics) Metrics {
214222
// We use the ceiling of the provided cell width and height to ensure
215223
// that the cell is large enough for the provided size, since we cast
216224
// it to an integer later.
217-
const cell_width = @ceil(face.cell_width);
218-
const cell_height = @ceil(face.lineHeight());
225+
const face_width = face.cell_width;
226+
const face_height = face.lineHeight();
227+
const cell_width = @ceil(face_width);
228+
const cell_height = @ceil(face_height);
219229

220230
// We split our line gap in two parts, and put half of it on the top
221231
// 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 {
224234

225235
// Unlike all our other metrics, `cell_baseline` is relative to the
226236
// BOTTOM of the cell.
227-
const cell_baseline = @round(half_line_gap - face.descent);
237+
const face_baseline = half_line_gap - face.descent;
238+
const cell_baseline = @round(face_baseline);
239+
240+
// We keep track of the vertical bearing of the face in the cell
241+
const face_y = cell_baseline - face_baseline;
228242

229243
// We calculate a top_to_baseline to make following calculations simpler.
230244
const top_to_baseline = cell_height - cell_baseline;
@@ -237,16 +251,8 @@ pub fn calc(face: FaceMetrics) Metrics {
237251
const underline_position = @round(top_to_baseline - face.underlinePosition());
238252
const strikethrough_position = @round(top_to_baseline - face.strikethroughPosition());
239253

240-
// The calculation for icon height in the nerd fonts patcher
241-
// is two thirds cap height to one third line height, but we
242-
// use an opinionated default of 1.2 * cap height instead.
243-
//
244-
// Doing this prevents fonts with very large line heights
245-
// from having excessively oversized icons, and allows fonts
246-
// with very small line heights to still have roomy icons.
247-
//
248-
// We do cap it at `cell_height` though for obvious reasons.
249-
const icon_height = @min(cell_height, cap_height * 1.2);
254+
// Same heuristic as the font_patcher script
255+
const icon_height = (2 * cap_height + face_height) / 3;
250256

251257
var result: Metrics = .{
252258
.cell_width = @intFromFloat(cell_width),
@@ -260,7 +266,10 @@ pub fn calc(face: FaceMetrics) Metrics {
260266
.overline_thickness = @intFromFloat(underline_thickness),
261267
.box_thickness = @intFromFloat(underline_thickness),
262268
.cursor_height = @intFromFloat(cell_height),
263-
.icon_height = @intFromFloat(icon_height),
269+
.icon_height = icon_height,
270+
.face_width = face_width,
271+
.face_height = face_height,
272+
.face_y = face_y,
264273
};
265274

266275
// Ensure all metrics are within their allowable range.
@@ -286,11 +295,6 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
286295
const new = @max(entry.value_ptr.apply(original), 1);
287296
if (new == original) continue;
288297

289-
// Preserve the original cell width if not set.
290-
if (self.original_cell_width == null) {
291-
self.original_cell_width = self.cell_width;
292-
}
293-
294298
// Set the new value
295299
@field(self, @tagName(tag)) = new;
296300

@@ -307,6 +311,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
307311
const diff = new - original;
308312
const diff_bottom = diff / 2;
309313
const diff_top = diff - diff_bottom;
314+
self.face_y += @floatFromInt(diff_bottom);
310315
self.cell_baseline +|= diff_bottom;
311316
self.underline_position +|= diff_top;
312317
self.strikethrough_position +|= diff_top;
@@ -315,6 +320,7 @@ pub fn apply(self: *Metrics, mods: ModifierSet) void {
315320
const diff = original - new;
316321
const diff_bottom = diff / 2;
317322
const diff_top = diff - diff_bottom;
323+
self.face_y -= @floatFromInt(diff_bottom);
318324
self.cell_baseline -|= diff_bottom;
319325
self.underline_position -|= diff_top;
320326
self.strikethrough_position -|= diff_top;
@@ -417,25 +423,35 @@ pub const Modifier = union(enum) {
417423
/// Apply a modifier to a numeric value.
418424
pub fn apply(self: Modifier, v: anytype) @TypeOf(v) {
419425
const T = @TypeOf(v);
420-
const signed = @typeInfo(T).int.signedness == .signed;
421-
return switch (self) {
422-
.percent => |p| percent: {
423-
const p_clamped: f64 = @max(0, p);
424-
const v_f64: f64 = @floatFromInt(v);
425-
const applied_f64: f64 = @round(v_f64 * p_clamped);
426-
const applied_T: T = @intFromFloat(applied_f64);
427-
break :percent applied_T;
426+
const Tinfo = @typeInfo(T);
427+
return switch (comptime Tinfo) {
428+
.int, .comptime_int => switch (self) {
429+
.percent => |p| percent: {
430+
const p_clamped: f64 = @max(0, p);
431+
const v_f64: f64 = @floatFromInt(v);
432+
const applied_f64: f64 = @round(v_f64 * p_clamped);
433+
const applied_T: T = @intFromFloat(applied_f64);
434+
break :percent applied_T;
435+
},
436+
437+
.absolute => |abs| absolute: {
438+
const v_i64: i64 = @intCast(v);
439+
const abs_i64: i64 = @intCast(abs);
440+
const applied_i64: i64 = v_i64 +| abs_i64;
441+
const clamped_i64: i64 = if (Tinfo.int.signedness == .signed)
442+
applied_i64
443+
else
444+
@max(0, applied_i64);
445+
const applied_T: T = std.math.cast(T, clamped_i64) orelse
446+
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
447+
break :absolute applied_T;
448+
},
428449
},
429-
430-
.absolute => |abs| absolute: {
431-
const v_i64: i64 = @intCast(v);
432-
const abs_i64: i64 = @intCast(abs);
433-
const applied_i64: i64 = v_i64 +| abs_i64;
434-
const clamped_i64: i64 = if (signed) applied_i64 else @max(0, applied_i64);
435-
const applied_T: T = std.math.cast(T, clamped_i64) orelse
436-
std.math.maxInt(T) * @as(T, @intCast(std.math.sign(clamped_i64)));
437-
break :absolute applied_T;
450+
.float, .comptime_float => return switch (self) {
451+
.percent => |p| v * @max(0, p),
452+
.absolute => |abs| v + @as(T, @floatFromInt(abs)),
438453
},
454+
else => {},
439455
};
440456
}
441457

@@ -481,7 +497,7 @@ pub const Key = key: {
481497
var enumFields: [field_infos.len]std.builtin.Type.EnumField = undefined;
482498
var count: usize = 0;
483499
for (field_infos, 0..) |field, i| {
484-
if (field.type != u32 and field.type != i32) continue;
500+
if (field.type != u32 and field.type != i32 and field.type != f64) continue;
485501
enumFields[i] = .{ .name = field.name, .value = i };
486502
count += 1;
487503
}
@@ -512,7 +528,10 @@ fn init() Metrics {
512528
.overline_thickness = 0,
513529
.box_thickness = 0,
514530
.cursor_height = 0,
515-
.icon_height = 0,
531+
.icon_height = 0.0,
532+
.face_width = 0.0,
533+
.face_height = 0.0,
534+
.face_y = 0.0,
516535
};
517536
}
518537

@@ -542,13 +561,15 @@ test "Metrics: adjust cell height smaller" {
542561
try set.put(alloc, .cell_height, .{ .percent = 0.75 });
543562

544563
var m: Metrics = init();
564+
m.face_y = 0.33;
545565
m.cell_baseline = 50;
546566
m.underline_position = 55;
547567
m.strikethrough_position = 30;
548568
m.overline_position = 0;
549569
m.cell_height = 100;
550570
m.cursor_height = 100;
551571
m.apply(set);
572+
try testing.expectEqual(-11.67, m.face_y);
552573
try testing.expectEqual(@as(u32, 75), m.cell_height);
553574
try testing.expectEqual(@as(u32, 38), m.cell_baseline);
554575
try testing.expectEqual(@as(u32, 42), m.underline_position);
@@ -570,13 +591,15 @@ test "Metrics: adjust cell height larger" {
570591
try set.put(alloc, .cell_height, .{ .percent = 1.75 });
571592

572593
var m: Metrics = init();
594+
m.face_y = 0.33;
573595
m.cell_baseline = 50;
574596
m.underline_position = 55;
575597
m.strikethrough_position = 30;
576598
m.overline_position = 0;
577599
m.cell_height = 100;
578600
m.cursor_height = 100;
579601
m.apply(set);
602+
try testing.expectEqual(37.33, m.face_y);
580603
try testing.expectEqual(@as(u32, 175), m.cell_height);
581604
try testing.expectEqual(@as(u32, 87), m.cell_baseline);
582605
try testing.expectEqual(@as(u32, 93), m.underline_position);

src/font/SharedGrid.zig

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,9 @@ pub fn renderGlyph(
270270
// Always use these constraints for emoji.
271271
if (p == .emoji) {
272272
render_opts.constraint = .{
273-
// Make the emoji as wide as possible, scaling proportionally,
274-
// but then scale it down as necessary if its new size exceeds
275-
// the cell height.
276-
.size_horizontal = .cover,
277-
.size_vertical = .fit,
273+
// Scale emoji to be as large as possible
274+
// while preserving their aspect ratio.
275+
.size = .cover,
278276

279277
// Center the emoji in its cells.
280278
.align_horizontal = .center,

0 commit comments

Comments
 (0)