From d32452950262b4ec2b50b0e0af66f7870c8e5d5d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 09:51:50 -0300 Subject: Embolden bold text's outline before it is rasterized, and drop the old stb_truetype sources Bold in the SDL shell was a smear of the finished glyph: every pixel took the larger of itself and three quarters of its left neighbour. That added a grey fringe on the right of each stem instead of a thicker stem, so bold read as blurred regular text, and at large sizes it barely read as bold at all. The rasterizer now does what Ghostty's FreeType face does: it loads the hinted glyph, thickens the outline with FT_Outline_Embolden, and renders that. The stems widen as one shape with hinted edges, and the advance is untouched, so the cell grid, caret and selection do not move. The strength is Ghostty's heuristic, 1/32 of the line height rounded up (one pixel at a 32 px line). A bitmap strike has no outline; it is widened by one whole pixel with FT_Bitmap_Embolden, the classic overstrike. Underline and strikethrough are still drawn into the cached cell mask (decorateLine). vendor/stb (stb_truetype.h and its implementation unit) was left over from the rasterizer FreeType replaced; nothing built or referenced it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/font.c | 30 ++++++++++++++++++++++++------ src/gui/font.h | 8 +++++--- src/gui/gui.zig | 46 +++++++++++++++++++++------------------------- 3 files changed, 50 insertions(+), 34 deletions(-) (limited to 'src/gui') diff --git a/src/gui/font.c b/src/gui/font.c index 5989dd4e..5ea595f9 100644 --- a/src/gui/font.c +++ b/src/gui/font.c @@ -3,6 +3,7 @@ // fallback, and the light target for shape-preserving grayscale text. #include "font.h" #include +#include #include #include @@ -126,7 +127,7 @@ void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, *ascent = asc > 0 ? asc : 1; } -static int raster(UIFont *f, float px, int32_t cp, uint8_t *out, +static int raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, int32_t stride, int32_t cell_w, int32_t cell_h, int32_t ascent, int centered) { if (!f || !out || stride < cell_w || cell_w <= 0 || cell_h <= 0) @@ -140,6 +141,22 @@ static int raster(UIFont *f, float px, int32_t cp, uint8_t *out, FT_Get_Char_Index(f->face, (FT_ULong)(uint32_t)cp); if (FT_Load_Glyph(f->face, glyph_index, load_flags()) != 0) return 0; + if (bold) { + // Synthetic bold thickens the hinted outline before it is rendered, so + // the wider stems are rasterized as one shape and keep hinted edges; the + // advance is untouched, as the cell grid requires. Strength is Ghostty's + // heuristic: 1/32 of the line height in 26.6, rounded up (one pixel at a + // 32 px line). A bitmap strike has no outline: widen it by one whole pixel, + // the classic overstrike, which keeps its pixels sharp. + FT_GlyphSlot slot = f->face->glyph; + if (slot->format == FT_GLYPH_FORMAT_OUTLINE) { + const FT_Pos height = f->face->size->metrics.height; + FT_Outline_Embolden(&slot->outline, (height + 31) / 32); + } else if (slot->format == FT_GLYPH_FORMAT_BITMAP && + FT_GlyphSlot_Own_Bitmap(slot) == 0) { + FT_Bitmap_Embolden(f->library, &slot->bitmap, 64, 0); + } + } if (FT_Render_Glyph(f->face->glyph, FT_RENDER_MODE_NORMAL) != 0) return 0; @@ -187,13 +204,14 @@ static int raster(UIFont *f, float px, int32_t cp, uint8_t *out, return 1; } -int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out, +int ui_font_raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, int32_t stride, int32_t cell_w, int32_t cell_h, int32_t ascent) { - return raster(f, px, cp, out, stride, cell_w, cell_h, ascent, 0); + return raster(f, px, cp, bold, out, stride, cell_w, cell_h, ascent, 0); } -int ui_font_raster_centered(UIFont *f, float px, int32_t cp, uint8_t *out, - int32_t stride, int32_t width, int32_t height) { - return raster(f, px, cp, out, stride, width, height, 0, 1); +int ui_font_raster_centered(UIFont *f, float px, int32_t cp, int bold, + uint8_t *out, int32_t stride, int32_t width, + int32_t height) { + return raster(f, px, cp, bold, out, stride, width, height, 0, 1); } diff --git a/src/gui/font.h b/src/gui/font.h index d965b418..4410bb7c 100644 --- a/src/gui/font.h +++ b/src/gui/font.h @@ -33,14 +33,16 @@ void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h, // Rasterize `cp` into `out` as a hinted grayscale cell mask (`stride` bytes per // row). The origin is the baseline `ascent` pixels from the top. Pixels outside // the glyph are zero. Missing codepoints render the face's .notdef glyph. +// `bold` emboldens the outline before rendering (synthetic bold, same advance). // Returns 1 when the glyph produced coverage, 0 for blank/error. -int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out, +int ui_font_raster(UIFont *f, float px, int32_t cp, int bold, uint8_t *out, int32_t stride, int32_t cell_w, int32_t cell_h, int32_t ascent); // Center bitmap ink bounds, independent of advance and baseline bearings. -int ui_font_raster_centered(UIFont *f, float px, int32_t cp, uint8_t *out, - int32_t stride, int32_t width, int32_t height); +int ui_font_raster_centered(UIFont *f, float px, int32_t cp, int bold, + uint8_t *out, int32_t stride, int32_t width, + int32_t height); void ui_font_free(UIFont *f); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 48087d0b..7ad9fe9a 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -253,7 +253,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { var bitmap: [128 * 128]u8 = @splat(0xaa); try std.testing.expectEqual( @as(c_int, 1), - c.ui_font_raster(font, size, 'H', &bitmap, stride, cell_w, cell_h, ascent), + c.ui_font_raster(font, size, 'H', 0, &bitmap, stride, cell_w, cell_h, ascent), ); var ink: usize = 0; @@ -269,7 +269,11 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { } try std.testing.expect(ink > 0); try std.testing.expect(soft > 0); - decorateGlyph(&bitmap, @intCast(stride), @intCast(cell_w), @intCast(cell_h), ascent, .{ .bold = true }); + // Synthetic bold thickens the outline inside the same cell. + try std.testing.expectEqual( + @as(c_int, 1), + c.ui_font_raster(font, size, 'H', 1, &bitmap, stride, cell_w, cell_h, ascent), + ); var bold_coverage: usize = 0; for (0..@intCast(cell_h)) |y| { for (0..@intCast(cell_w)) |x| bold_coverage += bitmap[y * @as(usize, @intCast(stride)) + x]; @@ -280,7 +284,7 @@ test "FreeType atlas raster is grayscale, bounded, and clears blank cells" { try std.testing.expectEqual( @as(c_int, 0), - c.ui_font_raster(font, size, ' ', &bitmap, stride, cell_w, cell_h, ascent), + c.ui_font_raster(font, size, ' ', 0, &bitmap, stride, cell_w, cell_h, ascent), ); for (0..@intCast(cell_h)) |y| for (0..@intCast(cell_w)) |x| @@ -326,6 +330,7 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { font, body_scale, 'H', + 0, &body, stride, cell_w, @@ -336,6 +341,7 @@ test "tagline glyph shrinks into its own cell and stays vertically centered" { font, tag.scale, 'H', + 0, &tagline, stride, @intCast(tag.width), @@ -2311,7 +2317,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer c.ui_font_free(g.font); defer gpa.free(g.scroll_edge); // grown on demand by stepScroll - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); + _ = c.ui_font_raster(font, scale, ' ', 0, atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; if (attach) |requested| return attachRequested(gpa, &g, requested); @@ -5896,7 +5902,7 @@ fn refitFont(g: *Gui, core: ?*pardes.Pardes) void { fn resetGlyphAtlas(g: *Gui) void { g.glyphs.clearRetainingCapacity(); @memset(g.atlas_stage, 0); - _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); + _ = c.ui_font_raster(g.font, g.scale, ' ', 0, g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; g.pen_y = 0; @@ -5938,11 +5944,11 @@ fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecorat const raster_w = if (centered) slot_w else if (role == .tagline) g.tagline_width else g.cell_w; const raster_h = if (role == .tagline) g.tagline_height else g.cell_h; if (centered) { - _ = c.ui_font_raster_centered(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h)); + _ = c.ui_font_raster_centered(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h)); } else { - _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline); + _ = c.ui_font_raster(face, scale, @intCast(cp), @intFromBool(decoration.bold), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline); } - decorateGlyph(out, atlas_w, raster_w, raster_h, baseline, decoration); + decorateLine(out, atlas_w, raster_w, raster_h, baseline, decoration); if (centered) centerGlyphInk(out, raster_w, raster_h); g.glyphs.put(key, s) catch return null; g.pen_x += slot_w; @@ -6056,23 +6062,13 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { } } -// Decorations stay inside the existing atlas slot, so text advance, caret and -// selection geometry are identical to the regular glyph. The cached mask keeps -// this work out of steady-state rendering and also decorates terminal spaces. -fn decorateGlyph(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void { +// Underline and strikethrough stay inside the existing atlas slot, so text +// advance, caret and selection geometry are identical to the regular glyph. +// The cached mask keeps this work out of steady-state rendering and also +// decorates terminal spaces. Bold is not drawn here: the rasterizer emboldens +// the outline. +fn decorateLine(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void { if (width == 0 or height == 0) return; - if (decoration.bold) { - // A fractional one-pixel embolden preserves counters at small UI sizes. - // Right-to-left traversal reads the original left neighbour each time. - for (0..height) |y| { - var x = width - 1; - while (x > 0) : (x -= 1) { - const previous = out[y * stride + x - 1]; - const target = &out[y * stride + x]; - target.* = @max(target.*, @as(u8, @intCast(@as(u16, previous) * 3 / 4))); - } - } - } if (decoration.underline != .off) { const base: usize = @intCast(@max(0, baseline)); const row = @min(base + 1, height - 1); @@ -6115,7 +6111,7 @@ test "glyph decorations render spaces and stay inside their atlas slot" { }) |decoration| { var mask: [stride * (height + 1)]u8 = @splat(0xa5); for (0..height) |y| @memset(mask[y * stride ..][0..width], 0); - decorateGlyph(&mask, stride, width, height, 5, decoration); + decorateLine(&mask, stride, width, height, 5, decoration); try std.testing.expect(inkBounds(&mask, stride, width, height) != null); for (0..height) |y| for (mask[y * stride + width ..][0 .. stride - width]) |guard| try std.testing.expectEqual(@as(u8, 0xa5), guard); -- cgit v1.3