a73x

86d04514

feat: add native font fallbacks

a73x   2026-09-07 10:57

Commit message
feat: add native font fallbacks

README.md
Old New
@@ -56,29 +56,35 @@ command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
56 Closing the window detaches all panes; their sessions stay on their daemons. When 56 Closing the window detaches all panes; their sessions stay on their daemons. When
57 a shell exits, its pane shows the exit status and the window remains open. 57 a shell exits, its pane shows the exit status and the window remains open.
58 `muxg` reads `$XDG_CONFIG_HOME/mux/config`, or `~/.config/mux/config` when 58 `muxg` reads `$XDG_CONFIG_HOME/mux/config`, or `~/.config/mux/config` when
59 that variable is unset or empty. For example, with this font installed: 59 that variable is unset or empty. For example, with these fonts installed:
60 60
61 ```ini 61 ```ini
62 font-family = "CommitMono Nerd Font Mono" 62 font-family = "CommitMono Nerd Font Mono"
63 font-family = "Adwaita Mono"
63 font-size = 12.4 64 font-size = 12.4
64 ``` 65 ```
65 66
66 The supported [Ghostty-style settings](https://ghostty.org/docs/config/reference#font-size) 67 The supported [Ghostty-style settings](https://ghostty.org/docs/config/reference#font-family)
67 are one installed monospace `font-family` and a `font-size` from 1–192 points. 68 are an ordered list of installed monospace `font-family` values and a
69 `font-size` from 1–192 points. The first family supplies grid metrics; each
70 later family is tried when an entire cell grapheme is unavailable in the
71 families before it.
68 Fractional sizes are preserved until rasterization: points × 96/72 on Linux 72 Fractional sizes are preserved until rasterization: points × 96/72 on Linux
69 (points × 1 on macOS), then display scale, rounded to a pixel. Blank lines and 73 (points × 1 on macOS), then display scale, rounded to a pixel. Blank lines and
70 full-line `#` comments are accepted; a family may be double quoted. Duplicate 74 full-line `#` comments are accepted; a family may be double quoted. Duplicate
71 keys, unknown mux config keys, malformed values and unavailable families produce diagnostics. 75 singleton keys, unknown mux config keys, malformed values and unavailable
72 Repeated fallback families, per-style families, escapes and inline comments are 76 families produce diagnostics. Per-style families, escapes and inline comments
73 not supported. This is a supported subset of Ghostty appearance settings. 77 are not supported. This is a supported subset of Ghostty appearance settings.
74 78
75 `--font-family NAME` and `--font-size POINTS` override the config. The existing 79 `--font-family NAME` may be repeated to supply an ordered CLI family list; any
80 CLI family replaces the complete config list. `--font-size POINTS` overrides
81 the config size. The existing
76 `--font-px N` (1–256 pixels at 100% scale) also overrides config sizing; choose 82 `--font-px N` (1–256 pixels at 100% scale) also overrides config sizing; choose
77 one size flag. Without config the original font (monospace, 16 logical pixels) 83 one size flag. Without config the original font (monospace, 16 logical pixels)
78 and colors remain. Close and reopen the client to apply config edits; daemon 84 and colors remain. Close and reopen the client to apply config edits; daemon
79 sessions survive. Glyphs refresh automatically when display scale changes, 85 sessions survive. Glyphs refresh automatically when display scale changes,
80 retaining the selected family. Installed Nerd Font Mono icons use that face; 86 retaining the family list. Explicit fallback families are supported; automatic
81 font fallback and cross-cell programming ligatures are not implemented. 87 system fallback, colour emoji and cross-cell programming ligatures are not.
82 88
83 In the native GUI, a left drag selects and copies text unless the application 89 In the native GUI, a left drag selects and copies text unless the application
84 has requested mouse reporting. Applications such as tmux then receive ordinary 90 has requested mouse reporting. Applications such as tmux then receive ordinary
src/cli/muxg.zig
Old New
@@ -32,7 +32,7 @@ const usage =
32 \\ --via a command whose stdio is the daemon 32 \\ --via a command whose stdio is the daemon
33 \\ --key the QUIC key file (or MUX_KEY_FILE) 33 \\ --key the QUIC key file (or MUX_KEY_FILE)
34 \\ --font-px font pixels at 100% display scale (default 16) 34 \\ --font-px font pixels at 100% display scale (default 16)
35 \\ --font-family font family (config: font-family) 35 \\ --font-family ordered font family; repeat for fallbacks (config: font-family)
36 \\ --font-size font size in points, 1–192 (config: font-size) 36 \\ --font-size font size in points, 1–192 (config: font-size)
37 \\ --theme theme filename in config themes directory or absolute path 37 \\ --theme theme filename in config themes directory or absolute path
38 \\ --background, --foreground, --cursor-color explicit 6-digit colour 38 \\ --background, --foreground, --cursor-color explicit 6-digit colour
@@ -47,13 +47,15 @@ const Arguments = struct {
47 key: ?[]const u8 = null, 47 key: ?[]const u8 = null,
48 session: ?proto.SessionName = null, 48 session: ?proto.SessionName = null,
49 font_px: ?u16 = null, 49 font_px: ?u16 = null,
50 font_family: ?[]const u8 = null,
51 font_size: ?PointSize = null, 50 font_size: ?PointSize = null,
52 theme: ?[]const u8 = null, 51 theme: ?[]const u8 = null,
53 background: ?Color = null, 52 background: ?Color = null,
54 foreground: ?Color = null, 53 foreground: ?Color = null,
55 cursor_color: ?Color = null, 54 cursor_color: ?Color = null,
56 _palette_values: [256]?u32 = [_]?u32{null} ** 256, 55 _palette_values: [256]?u32 = [_]?u32{null} ** 256,
56 _font_families: std.ArrayListUnmanaged([:0]const u8) = .empty,
57 _argv_alloc: std.mem.Allocator = undefined,
58 _font_family_oom: bool = false,
57 _target: ?[]const u8 = null, 59 _target: ?[]const u8 = null,
58 _targets: usize = 0, 60 _targets: usize = 0,
59 61
@@ -64,10 +66,20 @@ const Arguments = struct {
64 } 66 }
65 67
66 pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize { 68 pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize {
67 if (!std.mem.eql(u8, rest[0], "--palette") or rest.len < 2) return 0; 69 if (std.mem.eql(u8, rest[0], "--font-family")) {
68 const pair = native.config.parsePalette(rest[1]) catch return 0; 70 if (rest.len < 2) return 0;
69 self._palette_values[pair.index] = pair.color; 71 self._font_families.append(self._argv_alloc, rest[1]) catch {
70 return 2; 72 self._font_family_oom = true;
73 };
74 return 2;
75 }
76 if (std.mem.eql(u8, rest[0], "--palette")) {
77 if (rest.len < 2) return 0;
78 const pair = native.config.parsePalette(rest[1]) catch return 0;
79 self._palette_values[pair.index] = pair.color;
80 return 2;
81 }
82 return 0;
71 } 83 }
72 }; 84 };
73 85
@@ -85,13 +97,16 @@ pub fn main() !u8 {
85 const args = try std.process.argsAlloc(argv_alloc); 97 const args = try std.process.argsAlloc(argv_alloc);
86 if (try client.resolver.helper(alloc, args[1..])) |code| return code; 98 if (try client.resolver.helper(alloc, args[1..])) |code| return code;
87 99
88 var o: Arguments = .{}; 100 var o: Arguments = .{ ._argv_alloc = argv_alloc };
101 defer o._font_families.deinit(argv_alloc);
89 cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("{s} ({s})", .{ @import("build_options").version, @tagName(@import("builtin").mode) })); 102 cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("{s} ({s})", .{ @import("build_options").version, @tagName(@import("builtin").mode) }));
103 if (o._font_family_oom) return error.OutOfMemory;
90 if (o.font_px != null and o.font_size != null) { 104 if (o.font_px != null and o.font_size != null) {
91 std.debug.print("muxg: --font-px and --font-size are ambiguous; choose one\n", .{}); 105 std.debug.print("muxg: --font-px and --font-size are ambiguous; choose one\n", .{});
92 return 2; 106 return 2;
93 } 107 }
94 var settings: native.config.Settings = .{}; 108 var settings: native.config.Settings = .{};
109 defer settings.deinit(argv_alloc);
95 var config_line: usize = 1; 110 var config_line: usize = 1;
96 const config_path = xdg.pathFrom(argv_alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"), ".config", "config") catch |err| switch (err) { 111 const config_path = xdg.pathFrom(argv_alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"), ".config", "config") catch |err| switch (err) {
97 error.NoHome => null, 112 error.NoHome => null,
@@ -100,7 +115,7 @@ pub fn main() !u8 {
100 if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| { 115 if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| {
101 const reason = switch (err) { 116 const reason = switch (err) {
102 error.UnknownKey => "unknown key; supported keys: font-family, font-size, theme, foreground, background, cursor-color, palette", 117 error.UnknownKey => "unknown key; supported keys: font-family, font-size, theme, foreground, background, cursor-color, palette",
103 error.InvalidSyntax => "expected one key = value per line, without duplicate keys", 118 error.InvalidSyntax => "expected one key = value per line; only font-family may repeat",
104 error.InvalidValue => "font-size must be a finite number between 1 and 192 points", 119 error.InvalidValue => "font-size must be a finite number between 1 and 192 points",
105 error.InvalidColor => "colour must be exactly six hexadecimal digits", 120 error.InvalidColor => "colour must be exactly six hexadecimal digits",
106 error.InvalidPalette => "palette must be N=RRGGBB with N between 0 and 255", 121 error.InvalidPalette => "palette must be N=RRGGBB with N between 0 and 255",
@@ -150,6 +165,12 @@ pub fn main() !u8 {
150 } 165 }
151 const session = if (o.session) |n| n.name else ""; 166 const session = if (o.session) |n| n.name else "";
152 const key = std.posix.getenv("MUX_KEY_FILE"); 167 const key = std.posix.getenv("MUX_KEY_FILE");
168 const font_families: []const [:0]const u8 = if (o._font_families.items.len != 0)
169 o._font_families.items
170 else if (settings.families.len != 0)
171 settings.families
172 else
173 native.font.default_families;
153 174
154 const temporary = named != 0 or o.session != null; 175 const temporary = named != 0 or o.session != null;
155 const local_path: ?[]const u8 = if (o.sock) |path| path else if (o._target != null or o.via != null) null else if (temporary) (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) else sockpath.defaultSockPath(argv_alloc) catch null; 176 const local_path: ?[]const u8 = if (o.sock) |path| path else if (o._target != null or o.via != null) null else if (temporary) (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) else sockpath.defaultSockPath(argv_alloc) catch null;
@@ -165,7 +186,7 @@ pub fn main() !u8 {
165 .key_path = o.key orelse key, 186 .key_path = o.key orelse key,
166 .session = session, 187 .session = session,
167 .font_px = font_px, 188 .font_px = font_px,
168 .font_family = o.font_family orelse settings.family orelse "monospace", 189 .font_families = font_families,
169 .font_points = font_points, 190 .font_points = font_points,
170 .appearance = native.theme.merge(native.theme.legacy, selected, explicit), 191 .appearance = native.theme.merge(native.theme.legacy, selected, explicit),
171 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 192 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
src/gui/atlas.zig
Old New
@@ -2,7 +2,7 @@
2 const std = @import("std"); 2 const std = @import("std");
3 3
4 pub const Variant = enum(u2) { regular, bold, italic, bold_italic }; 4 pub const Variant = enum(u2) { regular, bold, italic, bold_italic };
5 pub const Key = struct { variant: Variant, glyph_id: u32 }; 5 pub const Key = struct { face_id: u32, variant: Variant, glyph_id: u32 };
6 pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 }; 6 pub const Entry = struct { x: u16, y: u16, w: u16, h: u16, left: i16, top: i16 };
7 7
8 pub const Atlas = struct { 8 pub const Atlas = struct {
@@ -60,14 +60,16 @@ pub const Atlas = struct {
60 } 60 }
61 }; 61 };
62 62
63 test "variant key separation and growth preserve coordinates" { 63 test "face and variant key separation preserve coordinates through growth" {
64 const a = std.testing.allocator; 64 const a = std.testing.allocator;
65 var at = try Atlas.init(a, 4, 2); 65 var at = try Atlas.init(a, 4, 2);
66 defer at.deinit(a); 66 defer at.deinit(a);
67 const px = [_]u8{ 1, 2, 3, 4 }; 67 const px = [_]u8{ 1, 2, 3, 4 };
68 const r = try at.put(a, .{ .variant = .regular, .glyph_id = 7 }, 2, 2, 0, 0, &px); 68 const r = try at.put(a, .{ .face_id = 0, .variant = .regular, .glyph_id = 7 }, 2, 2, 0, 0, &px);
69 _ = try at.put(a, .{ .variant = .bold, .glyph_id = 7 }, 2, 2, 0, 0, &px); 69 const fallback = try at.put(a, .{ .face_id = 1, .variant = .regular, .glyph_id = 7 }, 2, 2, 0, 0, &px);
70 _ = try at.put(a, .{ .variant = .italic, .glyph_id = 8 }, 2, 2, 0, 0, &px); 70 _ = try at.put(a, .{ .face_id = 0, .variant = .bold, .glyph_id = 7 }, 2, 2, 0, 0, &px);
71 try std.testing.expect(at.height > 2); 71 try std.testing.expect(at.height > 2);
72 try std.testing.expectEqual(r, at.get(.{ .variant = .regular, .glyph_id = 7 }).?); 72 try std.testing.expect(r.x != fallback.x or r.y != fallback.y);
73 try std.testing.expectEqual(r, at.get(.{ .face_id = 0, .variant = .regular, .glyph_id = 7 }).?);
74 try std.testing.expectEqual(fallback, at.get(.{ .face_id = 1, .variant = .regular, .glyph_id = 7 }).?);
73 } 75 }
src/gui/config.zig
Old New
@@ -3,7 +3,7 @@ const std = @import("std");
3 const font_options = @import("font_options.zig"); 3 const font_options = @import("font_options.zig");
4 4
5 pub const Settings = struct { 5 pub const Settings = struct {
6 family: ?[:0]const u8 = null, 6 families: []const [:0]const u8 = &.{},
7 size_points: ?f64 = null, 7 size_points: ?f64 = null,
8 theme: ?[:0]const u8 = null, 8 theme: ?[:0]const u8 = null,
9 theme_line: usize = 0, 9 theme_line: usize = 0,
@@ -11,6 +11,13 @@ pub const Settings = struct {
11 background: ?u32 = null, 11 background: ?u32 = null,
12 cursor_color: ?u32 = null, 12 cursor_color: ?u32 = null,
13 palette: [256]?u32 = [_]?u32{null} ** 256, 13 palette: [256]?u32 = [_]?u32{null} ** 256,
14
15 pub fn deinit(self: *Settings, alloc: std.mem.Allocator) void {
16 for (self.families) |family| alloc.free(family);
17 if (self.families.len != 0) alloc.free(self.families);
18 if (self.theme) |theme| alloc.free(theme);
19 self.* = .{};
20 }
14 }; 21 };
15 pub const Error = error{ InvalidSyntax, UnknownKey, InvalidValue, InvalidColor, InvalidPalette, InvalidThemeName, MissingFamily, OutOfMemory }; 22 pub const Error = error{ InvalidSyntax, UnknownKey, InvalidValue, InvalidColor, InvalidPalette, InvalidThemeName, MissingFamily, OutOfMemory };
16 23
@@ -45,8 +52,10 @@ fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 {
45 52
46 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Error!Settings { 53 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Error!Settings {
47 var out: Settings = .{}; 54 var out: Settings = .{};
55 var families: std.ArrayListUnmanaged([:0]const u8) = .empty;
48 errdefer { 56 errdefer {
49 if (out.family) |f| alloc.free(f); 57 for (families.items) |family| alloc.free(family);
58 families.deinit(alloc);
50 if (out.theme) |t| alloc.free(t); 59 if (out.theme) |t| alloc.free(t);
51 } 60 }
52 var it = std.mem.splitScalar(u8, bytes, '\n'); 61 var it = std.mem.splitScalar(u8, bytes, '\n');
@@ -65,10 +74,11 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Err
65 return error.InvalidSyntax; 74 return error.InvalidSyntax;
66 } 75 }
67 if (std.mem.eql(u8, key, "font-family")) { 76 if (std.mem.eql(u8, key, "font-family")) {
68 if (out.family != null) { 77 const family = try valueText(value, alloc);
69 return error.InvalidSyntax; 78 families.append(alloc, family) catch |err| {
70 } 79 alloc.free(family);
71 out.family = try valueText(value, alloc); 80 return err;
81 };
72 } else if (std.mem.eql(u8, key, "font-size")) { 82 } else if (std.mem.eql(u8, key, "font-size")) {
73 if (out.size_points != null) { 83 if (out.size_points != null) {
74 return error.InvalidSyntax; 84 return error.InvalidSyntax;
@@ -98,6 +108,7 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Err
98 return error.UnknownKey; 108 return error.UnknownKey;
99 } 109 }
100 } 110 }
111 out.families = try families.toOwnedSlice(alloc);
101 return out; 112 return out;
102 } 113 }
103 114
@@ -110,21 +121,28 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8, line_out: ?*usize) !Sett
110 } 121 }
111 122
112 test "config parses comments quotes and decimal points" { 123 test "config parses comments quotes and decimal points" {
113 const s = try parse(std.testing.allocator, "# native\nfont-family = \"Noto Sans Mono\"\nfont-size = 12.5\n", null); 124 var s = try parse(std.testing.allocator, "# native\nfont-family = \"Noto Sans Mono\"\nfont-size = 12.5\n", null);
114 defer std.testing.allocator.free(s.family.?); 125 defer s.deinit(std.testing.allocator);
115 try std.testing.expectEqualStrings("Noto Sans Mono", s.family.?); 126 try std.testing.expectEqual(@as(usize, 1), s.families.len);
127 try std.testing.expectEqualStrings("Noto Sans Mono", s.families[0]);
116 try std.testing.expectEqual(@as(f64, 12.5), s.size_points.?); 128 try std.testing.expectEqual(@as(f64, 12.5), s.size_points.?);
117 try std.testing.expectError(error.UnknownKey, parse(std.testing.allocator, "font-weight = bold\n", null)); 129 try std.testing.expectError(error.UnknownKey, parse(std.testing.allocator, "font-weight = bold\n", null));
118 try std.testing.expectError(error.InvalidValue, parse(std.testing.allocator, "font-size = nan\n", null)); 130 try std.testing.expectError(error.InvalidValue, parse(std.testing.allocator, "font-size = nan\n", null));
119 } 131 }
120 132
121 test "config refuses duplicates and malformed text at the actual line" { 133 test "config preserves repeated font families in fallback order" {
134 var s = try parse(std.testing.allocator, "font-family = Primary Mono\nfont-family = \"Adwaita Mono\"\n", null);
135 defer s.deinit(std.testing.allocator);
136 try std.testing.expectEqual(@as(usize, 2), s.families.len);
137 try std.testing.expectEqualStrings("Primary Mono", s.families[0]);
138 try std.testing.expectEqualStrings("Adwaita Mono", s.families[1]);
139 }
140
141 test "config refuses singleton duplicates and malformed text at the actual line" {
122 const a = std.testing.allocator; 142 const a = std.testing.allocator;
123 var line: usize = 0; 143 var line: usize = 0;
124 try std.testing.expectError(error.InvalidSyntax, parse(a, "# header\nfont-family = \"broken\n", &line)); 144 try std.testing.expectError(error.InvalidSyntax, parse(a, "# header\nfont-family = \"broken\n", &line));
125 try std.testing.expectEqual(@as(usize, 2), line); 145 try std.testing.expectEqual(@as(usize, 2), line);
126 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = monospace\nfont-family = second\n", &line));
127 try std.testing.expectEqual(@as(usize, 2), line);
128 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-size = 12\nfont-size = 13\n", &line)); 146 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-size = 12\nfont-size = 13\n", &line));
129 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = mono\x00space\n", null)); 147 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = mono\x00space\n", null));
130 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = \"mono\"space\"\n", null)); 148 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = \"mono\"space\"\n", null));
src/gui/font.zig
Old New
@@ -1,5 +1,6 @@
1 //! Fontconfig-selected monospace variants, full per-cell HarfBuzz shaping, 1 //! Ordered Fontconfig-selected monospace families, whole-cell HarfBuzz
2 //! and FreeType rasterization by glyph ID. No fallback or colour faces. 2 //! shaping, and FreeType rasterization by face and glyph ID. No automatic
3 //! system fallback or colour faces.
3 const std = @import("std"); 4 const std = @import("std");
4 const atlas = @import("atlas.zig"); 5 const atlas = @import("atlas.zig");
5 const quads = @import("quads.zig"); 6 const quads = @import("quads.zig");
@@ -191,6 +192,9 @@ pub const Face = struct {
191 for (out, 0..) |*g, i| g.* = .{ .glyph_id = infos[i].codepoint, .x_advance = pos[i].x_advance, .y_advance = pos[i].y_advance, .x_offset = pos[i].x_offset, .y_offset = pos[i].y_offset }; 192 for (out, 0..) |*g, i| g.* = .{ .glyph_id = infos[i].codepoint, .x_advance = pos[i].x_advance, .y_advance = pos[i].y_advance, .x_offset = pos[i].x_offset, .y_offset = pos[i].y_offset };
192 return .{ .glyphs = out }; 193 return .{ .glyphs = out };
193 } 194 }
195 pub fn hasCodepoint(self: *const Face, v: Variant, cp: u21) bool {
196 return c.FT_Get_Char_Index(self.handles[@intFromEnum(v)].face, cp) != 0;
197 }
194 pub fn renderGlyph(self: *Face, alloc: std.mem.Allocator, v: Variant, id: u32) Error!Glyph { 198 pub fn renderGlyph(self: *Face, alloc: std.mem.Allocator, v: Variant, id: u32) Error!Glyph {
195 const h = self.handle(v); 199 const h = self.handle(v);
196 if (c.FT_Load_Glyph(h.face, id, c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad; 200 if (c.FT_Load_Glyph(h.face, id, c.FT_LOAD_DEFAULT) != 0) return error.GlyphLoad;
@@ -212,14 +216,80 @@ pub const Face = struct {
212 } 216 }
213 }; 217 };
214 218
219 pub const default_families: []const [:0]const u8 = &.{"monospace"};
220
221 fn clusterFace(faces: anytype, text: []const u8, variant: Variant) u32 {
222 for (faces, 0..) |*face, face_id| {
223 var offset: usize = 0;
224 while (offset < text.len) {
225 const len = std.unicode.utf8ByteSequenceLength(text[offset]) catch break;
226 if (offset + len > text.len) break;
227 const cp = std.unicode.utf8Decode(text[offset .. offset + len]) catch break;
228 offset += len;
229 // Joiners and presentation selectors affect shaping but do not
230 // require standalone glyphs from the selected face.
231 if (cp == 0x200d or cp == 0xfe0e or cp == 0xfe0f) continue;
232 if (!face.hasCodepoint(variant, cp)) break;
233 } else return @intCast(face_id);
234 }
235 // Preserve the existing missing-glyph behavior when no configured family
236 // covers the cluster: HarfBuzz shapes it with the primary face's .notdef.
237 return 0;
238 }
239
240 /// Eagerly opened families in configured priority order. The primary face
241 /// owns terminal metrics; fallback metrics only position glyphs inside that
242 /// authoritative cell grid.
243 pub const FontSet = struct {
244 faces: []Face,
245 families: []const [:0]const u8,
246
247 pub fn open(alloc: std.mem.Allocator, px: u16, families: []const [:0]const u8, failed_family: ?*usize) Face.Error!FontSet {
248 if (families.len == 0) return error.NoMonospaceFace;
249 const faces = try alloc.alloc(Face, families.len);
250 var made: usize = 0;
251 errdefer {
252 for (faces[0..made]) |*loaded_face| loaded_face.deinit();
253 alloc.free(faces);
254 }
255 for (families, 0..) |family, i| {
256 if (failed_family) |failed| failed.* = i;
257 faces[i] = try Face.openFamily(px, family);
258 made += 1;
259 }
260 return .{ .faces = faces, .families = families };
261 }
262
263 pub fn openDefault(alloc: std.mem.Allocator, px: u16) Face.Error!FontSet {
264 return open(alloc, px, default_families, null);
265 }
266
267 pub fn deinit(self: *FontSet, alloc: std.mem.Allocator) void {
268 for (self.faces) |*loaded_face| loaded_face.deinit();
269 alloc.free(self.faces);
270 self.* = undefined;
271 }
272
273 pub fn primary(self: *FontSet) *Face {
274 return &self.faces[0];
275 }
276
277 fn face(self: *FontSet, face_id: u32) *Face {
278 return &self.faces[@intCast(face_id)];
279 }
280
281 fn faceForCluster(self: *FontSet, text: []const u8, variant: Variant) u32 {
282 return clusterFace(self.faces, text, variant);
283 }
284 };
285
215 /// Owns complete shaped-cluster keys and the positioned atlas runs they map 286 /// Owns complete shaped-cluster keys and the positioned atlas runs they map
216 /// to. Call `prepare` for every visible cell before quad generation; after 287 /// to. Call `prepare` for every visible cell before quad generation; after
217 /// that, `resolve` performs no insertion, so one frame uses one atlas size. 288 /// that, `resolve` performs no insertion, so one frame uses one atlas size.
218 pub const GlyphCache = struct { 289 pub const GlyphCache = struct {
219 alloc: std.mem.Allocator, 290 alloc: std.mem.Allocator,
220 face: *Face, 291 fonts: *FontSet,
221 glyph_atlas: *atlas.Atlas, 292 glyph_atlas: *atlas.Atlas,
222 family: []const u8 = "monospace",
223 runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty, 293 runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty,
224 294
225 pub fn deinit(self: *GlyphCache) void { 295 pub fn deinit(self: *GlyphCache) void {
@@ -230,19 +300,19 @@ pub const GlyphCache = struct {
230 } 300 }
231 self.runs.deinit(self.alloc); 301 self.runs.deinit(self.alloc);
232 } 302 }
233 /// Keep face and atlas addresses stable for the renderer. Build both 303 /// Keep font-set and atlas addresses stable for the renderer. Build both
234 /// replacements first, so an allocation/font failure leaves the current 304 /// replacements first, so an allocation/font failure leaves the current
235 /// cache usable; glyph IDs and bitmap coordinates never cross sizes. 305 /// cache usable; glyph IDs and bitmap coordinates never cross sizes.
236 pub fn setPixelSize(self: *GlyphCache, px: u16) !bool { 306 pub fn setPixelSize(self: *GlyphCache, px: u16) !bool {
237 if (self.face.pixels == px) return false; 307 if (self.fonts.primary().pixels == px) return false;
238 var next_face = try Face.openFamily(px, self.family); 308 var next_fonts = try FontSet.open(self.alloc, px, self.fonts.families, null);
239 errdefer next_face.deinit(); 309 errdefer next_fonts.deinit(self.alloc);
240 var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256); 310 var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256);
241 next_atlas.dirty = true; 311 next_atlas.dirty = true;
242 self.deinit(); 312 self.deinit();
243 self.face.deinit(); 313 self.fonts.deinit(self.alloc);
244 self.glyph_atlas.deinit(self.alloc); 314 self.glyph_atlas.deinit(self.alloc);
245 self.face.* = next_face; 315 self.fonts.* = next_fonts;
246 self.glyph_atlas.* = next_atlas; 316 self.glyph_atlas.* = next_atlas;
247 self.runs = .empty; 317 self.runs = .empty;
248 return true; 318 return true;
@@ -260,14 +330,16 @@ pub const GlyphCache = struct {
260 lookup_buf[0] = @intFromEnum(variant); 330 lookup_buf[0] = @intFromEnum(variant);
261 @memcpy(lookup_buf[1 .. text.len + 1], text); 331 @memcpy(lookup_buf[1 .. text.len + 1], text);
262 if (self.runs.contains(lookup_buf[0 .. text.len + 1])) return; 332 if (self.runs.contains(lookup_buf[0 .. text.len + 1])) return;
263 var shaped = try self.face.shape(self.alloc, text, variant); 333 const face_id = self.fonts.faceForCluster(text, variant);
334 const selected = self.fonts.face(face_id);
335 var shaped = try selected.shape(self.alloc, text, variant);
264 defer shaped.deinit(self.alloc); 336 defer shaped.deinit(self.alloc);
265 const placed = try self.alloc.alloc(quads.PositionedGlyph, shaped.glyphs.len); 337 const placed = try self.alloc.alloc(quads.PositionedGlyph, shaped.glyphs.len);
266 errdefer self.alloc.free(placed); 338 errdefer self.alloc.free(placed);
267 for (shaped.glyphs, placed) |g, *p| { 339 for (shaped.glyphs, placed) |g, *p| {
268 const glyph_key: atlas.Key = .{ .variant = variant, .glyph_id = g.glyph_id }; 340 const glyph_key: atlas.Key = .{ .face_id = face_id, .variant = variant, .glyph_id = g.glyph_id };
269 const entry = self.glyph_atlas.get(glyph_key) orelse blk: { 341 const entry = self.glyph_atlas.get(glyph_key) orelse blk: {
270 var bitmap = try self.face.renderGlyph(self.alloc, variant, g.glyph_id); 342 var bitmap = try selected.renderGlyph(self.alloc, variant, g.glyph_id);
271 defer bitmap.deinit(self.alloc); 343 defer bitmap.deinit(self.alloc);
272 break :blk try self.glyph_atlas.put(self.alloc, glyph_key, bitmap.w, bitmap.h, bitmap.left, bitmap.top, bitmap.pixels); 344 break :blk try self.glyph_atlas.put(self.alloc, glyph_key, bitmap.w, bitmap.h, bitmap.left, bitmap.top, bitmap.pixels);
273 }; 345 };
@@ -306,17 +378,50 @@ test "cell width is the shaped monospace M advance" {
306 try std.testing.expectEqual(@as(i32, f.cell_w), @divTrunc(advance + 63, 64)); 378 try std.testing.expectEqual(@as(i32, f.cell_w), @divTrunc(advance + 63, 64));
307 } 379 }
308 380
381 test "fallback selection keeps a complete cluster on the first covering face" {
382 const TestFace = struct {
383 codepoints: []const u21,
384 fn hasCodepoint(self: *const @This(), _: Variant, cp: u21) bool {
385 return std.mem.indexOfScalar(u21, self.codepoints, cp) != null;
386 }
387 };
388 const base = [_]u21{'e'};
389 const complete = [_]u21{ 'e', 0x301 };
390 var faces = [_]TestFace{
391 .{ .codepoints = &base },
392 .{ .codepoints = &complete },
393 .{ .codepoints = &complete },
394 };
395 try std.testing.expectEqual(@as(u32, 0), clusterFace(faces[0..], "e", .regular));
396 try std.testing.expectEqual(@as(u32, 1), clusterFace(faces[0..], "e\xcc\x81", .regular));
397 try std.testing.expectEqual(@as(u32, 1), clusterFace(faces[0..], "e\xe2\x80\x8d\xcc\x81\xef\xb8\x8f", .regular));
398 try std.testing.expectEqual(@as(u32, 0), clusterFace(faces[0..], "z", .regular));
399 }
400
401 test "installed Adwaita Mono follows CommitMono for a missing glyph" {
402 const alloc = std.testing.allocator;
403 const families: []const [:0]const u8 = &.{ "CommitMono Nerd Font Mono", "Adwaita Mono" };
404 var fonts = FontSet.open(alloc, 16, families, null) catch |err| switch (err) {
405 error.NoMonospaceFace => return error.SkipZigTest,
406 else => return err,
407 };
408 defer fonts.deinit(alloc);
409 const cp: u21 = 0x416;
410 if (fonts.faces[0].hasCodepoint(.regular, cp) or !fonts.faces[1].hasCodepoint(.regular, cp)) return error.SkipZigTest;
411 try std.testing.expectEqual(@as(u32, 1), fonts.faceForCluster("\xd0\x96", .regular));
412 }
413
309 test "cache owns complete styled cluster and preserves shaping through atlas growth" { 414 test "cache owns complete styled cluster and preserves shaping through atlas growth" {
310 const alloc = std.testing.allocator; 415 const alloc = std.testing.allocator;
311 var face = try Face.open(16); 416 var fonts = try FontSet.openDefault(alloc, 16);
312 defer face.deinit(); 417 defer fonts.deinit(alloc);
313 var glyph_atlas = try atlas.Atlas.init(alloc, 128, 1); 418 var glyph_atlas = try atlas.Atlas.init(alloc, 128, 1);
314 defer glyph_atlas.deinit(alloc); 419 defer glyph_atlas.deinit(alloc);
315 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 420 var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
316 defer cache.deinit(); 421 defer cache.deinit();
317 422
318 var source = [_]u8{ 'e', 0xcc, 0x81 }; 423 var source = [_]u8{ 'e', 0xcc, 0x81 };
319 var expected = try face.shape(alloc, &source, .bold_italic); 424 var expected = try fonts.primary().shape(alloc, &source, .bold_italic);
320 defer expected.deinit(alloc); 425 defer expected.deinit(alloc);
321 try cache.prepare(&source, .bold_italic); 426 try cache.prepare(&source, .bold_italic);
322 source[0] = 'x'; 427 source[0] = 'x';
@@ -328,7 +433,7 @@ test "cache owns complete styled cluster and preserves shaping through atlas gro
328 try std.testing.expectEqual(want.y_advance, got.y_advance); 433 try std.testing.expectEqual(want.y_advance, got.y_advance);
329 try std.testing.expectEqual(want.x_offset, got.x_offset); 434 try std.testing.expectEqual(want.x_offset, got.x_offset);
330 try std.testing.expectEqual(want.y_offset, got.y_offset); 435 try std.testing.expectEqual(want.y_offset, got.y_offset);
331 try std.testing.expectEqual(got.entry, glyph_atlas.get(.{ .variant = .bold_italic, .glyph_id = want.glyph_id }).?); 436 try std.testing.expectEqual(got.entry, glyph_atlas.get(.{ .face_id = 0, .variant = .bold_italic, .glyph_id = want.glyph_id }).?);
332 const v1 = @as(f32, @floatFromInt(got.entry.y + got.entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height)); 437 const v1 = @as(f32, @floatFromInt(got.entry.y + got.entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height));
333 try std.testing.expect(v1 <= 1.0); 438 try std.testing.expect(v1 <= 1.0);
334 } 439 }
@@ -356,11 +461,11 @@ test "point sizes retain fractions through display scaling" {
356 461
357 test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" { 462 test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" {
358 const alloc = std.testing.allocator; 463 const alloc = std.testing.allocator;
359 var face = try Face.open(16); 464 var fonts = try FontSet.openDefault(alloc, 16);
360 defer face.deinit(); 465 defer fonts.deinit(alloc);
361 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256); 466 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
362 defer glyph_atlas.deinit(alloc); 467 defer glyph_atlas.deinit(alloc);
363 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 468 var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
364 defer cache.deinit(); 469 defer cache.deinit();
365 try cache.prepare("M", .regular); 470 try cache.prepare("M", .regular);
366 const small = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry; 471 const small = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
@@ -368,7 +473,7 @@ test "scale rebuild replaces glyph bitmaps and cached runs at stable resource ad
368 try std.testing.expectEqual(@as(usize, 1), cache.runs.count()); 473 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
369 for ([_]u16{ 32, 20, 16 }) |px| { 474 for ([_]u16{ 32, 20, 16 }) |px| {
370 try std.testing.expect(try cache.setPixelSize(px)); 475 try std.testing.expect(try cache.setPixelSize(px));
371 try std.testing.expectEqual(@as(u16, px), face.pixels); 476 try std.testing.expectEqual(@as(u16, px), fonts.primary().pixels);
372 try std.testing.expectEqual(@as(usize, 0), cache.runs.count()); 477 try std.testing.expectEqual(@as(usize, 0), cache.runs.count());
373 try std.testing.expectEqual(@as(u32, 0), glyph_atlas.entries.count()); 478 try std.testing.expectEqual(@as(u32, 0), glyph_atlas.entries.count());
374 try std.testing.expect(glyph_atlas.dirty); 479 try std.testing.expect(glyph_atlas.dirty);
@@ -381,26 +486,26 @@ test "scale rebuild replaces glyph bitmaps and cached runs at stable resource ad
381 try std.testing.expectEqual(small.w, glyph.w); 486 try std.testing.expectEqual(small.w, glyph.w);
382 try std.testing.expectEqual(small.h, glyph.h); 487 try std.testing.expectEqual(small.h, glyph.h);
383 } 488 }
384 try std.testing.expectEqual(@as(*Face, &face), cache.face); 489 try std.testing.expectEqual(@as(*FontSet, &fonts), cache.fonts);
385 try std.testing.expectEqual(@as(*atlas.Atlas, &glyph_atlas), cache.glyph_atlas); 490 try std.testing.expectEqual(@as(*atlas.Atlas, &glyph_atlas), cache.glyph_atlas);
386 } 491 }
387 } 492 }
388 493
389 test "failed scale atlas allocation keeps the old face and glyph cache usable" { 494 test "failed scale atlas allocation keeps the old font set and glyph cache usable" {
390 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); 495 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
391 const alloc = failing.allocator(); 496 const alloc = failing.allocator();
392 var face = try Face.open(16); 497 var fonts = try FontSet.openDefault(alloc, 16);
393 defer face.deinit(); 498 defer fonts.deinit(alloc);
394 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256); 499 var glyph_atlas = try atlas.Atlas.init(alloc, atlasWidth(16), 256);
395 defer glyph_atlas.deinit(alloc); 500 defer glyph_atlas.deinit(alloc);
396 var cache: GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 501 var cache: GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
397 defer cache.deinit(); 502 defer cache.deinit();
398 try cache.prepare("M", .regular); 503 try cache.prepare("M", .regular);
399 const previous = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry; 504 const previous = (try GlyphCache.resolve(&cache, "M", .regular))[0].entry;
400 const previous_pixels = glyph_atlas.pixels.ptr; 505 const previous_pixels = glyph_atlas.pixels.ptr;
401 failing.fail_index = failing.alloc_index; 506 failing.fail_index = failing.alloc_index + 1;
402 try std.testing.expectError(error.OutOfMemory, cache.setPixelSize(32)); 507 try std.testing.expectError(error.OutOfMemory, cache.setPixelSize(32));
403 try std.testing.expectEqual(@as(u16, 16), face.pixels); 508 try std.testing.expectEqual(@as(u16, 16), fonts.primary().pixels);
404 try std.testing.expectEqual(previous_pixels, glyph_atlas.pixels.ptr); 509 try std.testing.expectEqual(previous_pixels, glyph_atlas.pixels.ptr);
405 try std.testing.expectEqual(previous, (try GlyphCache.resolve(&cache, "M", .regular))[0].entry); 510 try std.testing.expectEqual(previous, (try GlyphCache.resolve(&cache, "M", .regular))[0].entry);
406 failing.fail_index = std.math.maxInt(usize); 511 failing.fail_index = std.math.maxInt(usize);
src/gui/frame.zig
Old New
@@ -29,7 +29,7 @@ pub const Options = struct {
29 session: []const u8 = "0", 29 session: []const u8 = "0",
30 /// Face pixel size at 100% display scale. 30 /// Face pixel size at 100% display scale.
31 font_px: u16 = 16, 31 font_px: u16 = 16,
32 font_family: []const u8 = "monospace", 32 font_families: []const [:0]const u8 = font.default_families,
33 font_points: ?f64 = null, 33 font_points: ?f64 = null,
34 appearance: theme_mod.Theme = theme_mod.legacy, 34 appearance: theme_mod.Theme = theme_mod.legacy,
35 width: u32 = 960, 35 width: u32 = 960,
@@ -497,7 +497,7 @@ const Events = struct {
497 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void { 497 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void {
498 defer self.syncCapture(); 498 defer self.syncCapture();
499 _ = try self.cache.setPixelSize(if (self.base_font_points) |points| font.scaledPoints(points, scale) else font.scaledPixels(self.base_font_px, scale)); 499 _ = try self.cache.setPixelSize(if (self.base_font_points) |points| font.scaledPoints(points, scale) else font.scaledPixels(self.base_font_px, scale));
500 const metrics = measuredMetrics(self.cache.face, scale); 500 const metrics = measuredMetrics(self.cache.fonts.primary(), scale);
501 try self.ui.updateGeometry(w, h, metrics); 501 try self.ui.updateGeometry(w, h, metrics);
502 self.geometry_dirty = false; 502 self.geometry_dirty = false;
503 } 503 }
@@ -596,11 +596,13 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
596 const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow"); 596 const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow");
597 defer c.SDL_DestroyWindow(win); 597 defer c.SDL_DestroyWindow(win);
598 const raster_px = if (opts.font_points) |points| font.scaledPoints(points, c.SDL_GetWindowDisplayScale(win)) else font.scaledPixels(opts.font_px, c.SDL_GetWindowDisplayScale(win)); 598 const raster_px = if (opts.font_points) |points| font.scaledPoints(points, c.SDL_GetWindowDisplayScale(win)) else font.scaledPixels(opts.font_px, c.SDL_GetWindowDisplayScale(win));
599 var face = font.Face.openFamily(raster_px, opts.font_family) catch |err| { 599 var failed_family: usize = 0;
600 std.debug.print("muxg: font family '{s}': {s}\n", .{ opts.font_family, @errorName(err) }); 600 var fonts = font.FontSet.open(alloc, raster_px, opts.font_families, &failed_family) catch |err| {
601 const family = if (failed_family < opts.font_families.len) opts.font_families[failed_family] else "<none>";
602 std.debug.print("muxg: font family '{s}': {s}\n", .{ family, @errorName(err) });
601 return 2; 603 return 2;
602 }; 604 };
603 defer face.deinit(); 605 defer fonts.deinit(alloc);
604 const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext"); 606 const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext");
605 defer _ = c.SDL_GL_DestroyContext(context); 607 defer _ = c.SDL_GL_DestroyContext(context);
606 _ = c.SDL_GL_SetSwapInterval(1); 608 _ = c.SDL_GL_SetSwapInterval(1);
@@ -612,7 +614,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
612 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput"); 614 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput");
613 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256); 615 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256);
614 defer glyph_atlas.deinit(alloc); 616 defer glyph_atlas.deinit(alloc);
615 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas, .family = opts.font_family }; 617 var cache: font.GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
616 defer cache.deinit(); 618 defer cache.deinit();
617 var lists: quads.Lists = .{}; 619 var lists: quads.Lists = .{};
618 defer lists.deinit(alloc); 620 defer lists.deinit(alloc);
@@ -622,7 +624,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
622 var fb_w: c_int = 0; 624 var fb_w: c_int = 0;
623 var fb_h: c_int = 0; 625 var fb_h: c_int = 0;
624 if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels"); 626 if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels");
625 const metrics = measuredMetrics(&face, c.SDL_GetWindowDisplayScale(win)); 627 const metrics = measuredMetrics(fonts.primary(), c.SDL_GetWindowDisplayScale(win));
626 var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) }; 628 var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) };
627 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents"); 629 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents");
628 var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring }); 630 var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring });
@@ -740,7 +742,8 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
740 } 742 }
741 const had_blink = visible_blink; 743 const had_blink = visible_blink;
742 visible_blink = false; 744 visible_blink = false;
743 const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase, .theme = appearance }; 745 const primary = fonts.primary();
746 const base_ctx: quads.Ctx = .{ .cell_w = primary.cell_w, .cell_h = primary.cell_h, .ascent = primary.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase, .theme = appearance };
744 for (events.ui.layout.items(), 0..) |p, i| { 747 for (events.ui.layout.items(), 0..) |p, i| {
745 if (p.visible.w == 0 or p.visible.h == 0) continue; 748 if (p.visible.w == 0 or p.visible.h == 0) continue;
746 const live = rt.get(p.id).?; 749 const live = rt.get(p.id).?;
@@ -1263,15 +1266,15 @@ test "divider drag preserves grab offset clamps signed outside motion and cancel
1263 1266
1264 test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" { 1267 test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" {
1265 const a = std.testing.allocator; 1268 const a = std.testing.allocator;
1266 var face = try font.Face.open(16); 1269 var fonts = try font.FontSet.openDefault(a, 16);
1267 defer face.deinit(); 1270 defer fonts.deinit(a);
1268 var glyph_atlas = try atlas.Atlas.init(a, font.atlasWidth(16), 256); 1271 var glyph_atlas = try atlas.Atlas.init(a, font.atlasWidth(16), 256);
1269 defer glyph_atlas.deinit(a); 1272 defer glyph_atlas.deinit(a);
1270 var cache: font.GlyphCache = .{ .alloc = a, .face = &face, .glyph_atlas = &glyph_atlas }; 1273 var cache: font.GlyphCache = .{ .alloc = a, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
1271 defer cache.deinit(); 1274 defer cache.deinit();
1272 var rt = runtime.Runtime.init(a, .{}); 1275 var rt = runtime.Runtime.init(a, .{});
1273 defer rt.deinit(); 1276 defer rt.deinit();
1274 const metrics = measuredMetrics(&face, 1); 1277 const metrics = measuredMetrics(fonts.primary(), 1);
1275 const first = try rt.add(.{ .via = "cat" }, "left", 960, 600, metrics); 1278 const first = try rt.add(.{ .via = "cat" }, "left", 960, 600, metrics);
1276 rt.workspace.arm(.beside); 1279 rt.workspace.arm(.beside);
1277 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); 1280 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics);
@@ -1299,12 +1302,12 @@ test "multi-pane scale transitions resize every content claim and preserve stabl
1299 for (events.ui.layout.items(), initial.items()) |p, before| { 1302 for (events.ui.layout.items(), initial.items()) |p, before| {
1300 try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1)); 1303 try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1));
1301 try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1)); 1304 try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1));
1302 try std.testing.expectEqual(@as(u32, face.cell_h), p.content.y); 1305 try std.testing.expectEqual(@as(u32, fonts.primary().cell_h), p.content.y);
1303 } 1306 }
1304 try cache.prepare("M", .regular); 1307 try cache.prepare("M", .regular);
1305 try events.updateGeometry(1600, 1000, 2.001); 1308 try events.updateGeometry(1600, 1000, 2.001);
1306 try std.testing.expectEqual(@as(usize, 1), cache.runs.count()); 1309 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
1307 try std.testing.expectEqual(@as(u16, 32), face.pixels); 1310 try std.testing.expectEqual(@as(u16, 32), fonts.primary().pixels);
1308 try events.updateGeometry(960, 600, 1); 1311 try events.updateGeometry(960, 600, 1);
1309 try std.testing.expectEqualDeep(initial.items(), events.ui.layout.items()); 1312 try std.testing.expectEqualDeep(initial.items(), events.ui.layout.items());
1310 try std.testing.expect(rt.accepts(old_key)); 1313 try std.testing.expect(rt.accepts(old_key));
@@ -1423,11 +1426,11 @@ test "paste shortcuts are platform-specific and exact" {
1423 1426
1424 test "later pane atlas growth precedes earlier pane UV generation" { 1427 test "later pane atlas growth precedes earlier pane UV generation" {
1425 const a = std.testing.allocator; 1428 const a = std.testing.allocator;
1426 var face = try font.Face.open(16); 1429 var fonts = try font.FontSet.openDefault(a, 16);
1427 defer face.deinit(); 1430 defer fonts.deinit(a);
1428 var glyph_atlas = try atlas.Atlas.init(a, 128, 1); 1431 var glyph_atlas = try atlas.Atlas.init(a, 128, 1);
1429 defer glyph_atlas.deinit(a); 1432 defer glyph_atlas.deinit(a);
1430 var cache: font.GlyphCache = .{ .alloc = a, .face = &face, .glyph_atlas = &glyph_atlas }; 1433 var cache: font.GlyphCache = .{ .alloc = a, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
1431 defer cache.deinit(); 1434 defer cache.deinit();
1432 const left = try term.grid.Grid.init(a, 1, 1); 1435 const left = try term.grid.Grid.init(a, 1, 1);
1433 defer left.deinit(); 1436 defer left.deinit();
@@ -1445,7 +1448,8 @@ test "later pane atlas growth precedes earlier pane UV generation" {
1445 try std.testing.expect(glyph_atlas.height > before); 1448 try std.testing.expect(glyph_atlas.height > before);
1446 const shaped = try font.GlyphCache.resolve(@ptrCast(&cache), "M", .regular); 1449 const shaped = try font.GlyphCache.resolve(@ptrCast(&cache), "M", .regular);
1447 const entry = shaped[0].entry; 1450 const entry = shaped[0].entry;
1448 const ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve } }; 1451 const primary = fonts.primary();
1452 const ctx: quads.Ctx = .{ .cell_w = primary.cell_w, .cell_h = primary.cell_h, .ascent = primary.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve } };
1449 var lists: quads.Lists = .{}; 1453 var lists: quads.Lists = .{};
1450 defer lists.deinit(a); 1454 defer lists.deinit(a);
1451 _ = try quads.rowInstances(&lists, a, left.row(0), 1, 0, 0, ctx); 1455 _ = try quads.rowInstances(&lists, a, left.row(0), 1, 0, 0, ctx);
test/native_fonts.py
Old New
@@ -25,6 +25,7 @@ from native_tiling import eventually, require
25 25
26 ICONS = '\uf07b \uf120 \ue0b0' 26 ICONS = '\uf07b \uf120 \ue0b0'
27 ASCII = 'MWil01_@# abcXYZ' 27 ASCII = 'MWil01_@# abcXYZ'
28 FALLBACK_CANDIDATES = (0x0416, 0x1F0A0, 0x1D400, 0x1FB00, 0x4DC0, 0x10780, 0x1CC00)
28 29
29 30
30 def config_path(rig): 31 def config_path(rig):
@@ -70,8 +71,15 @@ def signature(rig):
70 return state['cell_w'], state['cell_h'], signatures[0] 71 return state['cell_w'], state['cell_h'], signatures[0]
71 72
72 73
73 def font_has_icons(family): 74 def font_match(family):
74 result = subprocess.check_output(['fc-match', '-f', '%{file}\n%{index}\n', family], text=True).splitlines() 75 result = subprocess.check_output(
76 ['fc-match', '-f', '%{family[0]}\n%{file}\n%{index}\n%{spacing}\n', family],
77 text=True).splitlines()
78 return {'family': result[0], 'file': result[1], 'index': int(result[2]), 'spacing': result[3]}
79
80
81 def font_glyphs(family, codepoints):
82 result = font_match(family)
75 lib = ctypes.CDLL(ctypes.util.find_library('freetype')) 83 lib = ctypes.CDLL(ctypes.util.find_library('freetype'))
76 lib.FT_Init_FreeType.argtypes = [ctypes.POINTER(ctypes.c_void_p)] 84 lib.FT_Init_FreeType.argtypes = [ctypes.POINTER(ctypes.c_void_p)]
77 lib.FT_New_Face.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.POINTER(ctypes.c_void_p)] 85 lib.FT_New_Face.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, ctypes.POINTER(ctypes.c_void_p)]
@@ -82,19 +90,53 @@ def font_has_icons(family):
82 library, face = ctypes.c_void_p(), ctypes.c_void_p() 90 library, face = ctypes.c_void_p(), ctypes.c_void_p()
83 require(lib.FT_Init_FreeType(ctypes.byref(library)) == 0, 'FreeType oracle init') 91 require(lib.FT_Init_FreeType(ctypes.byref(library)) == 0, 'FreeType oracle init')
84 try: 92 try:
85 require(lib.FT_New_Face(library, os.fsencode(result[0]), int(result[1]), ctypes.byref(face)) == 0, 93 require(lib.FT_New_Face(library, os.fsencode(result['file']), result['index'], ctypes.byref(face)) == 0,
86 'FreeType oracle face') 94 'FreeType oracle face')
87 try: 95 try:
88 indices = [lib.FT_Get_Char_Index(face, ord(c)) for c in ICONS[::2]] 96 result['glyph_indices'] = [lib.FT_Get_Char_Index(face, cp) for cp in codepoints]
89 require(all(indices) and len(set(indices)) == 3, 'selected font lacks distinct Nerd Font icons') 97 return result
90 require(lib.FT_Get_Char_Index(face, 0x10ffff) == 0, 'missing-glyph control unexpectedly exists')
91 return {'file': result[0], 'glyph_indices': indices}
92 finally: 98 finally:
93 lib.FT_Done_Face(face) 99 lib.FT_Done_Face(face)
94 finally: 100 finally:
95 lib.FT_Done_FreeType(library) 101 lib.FT_Done_FreeType(library)
96 102
97 103
104 def font_has_icons(family):
105 result = font_glyphs(family, [*(ord(c) for c in ICONS[::2]), 0x10ffff])
106 indices = result['glyph_indices']
107 require(all(indices[:3]) and len(set(indices[:3])) == 3,
108 'selected font lacks distinct Nerd Font icons')
109 require(indices[3] == 0, 'missing-glyph control unexpectedly exists')
110 return {'file': result['file'], 'glyph_indices': indices[:3]}
111
112
113 def fallback_oracle(primary, fallback='Adwaita Mono'):
114 match = font_match(fallback)
115 if match['family'].casefold() != fallback.casefold() or match['spacing'] not in ('100', '110'):
116 return None
117 primary_result = font_glyphs(primary, FALLBACK_CANDIDATES)
118 fallback_result = font_glyphs(fallback, FALLBACK_CANDIDATES)
119 for cp, primary_glyph, fallback_glyph in zip(
120 FALLBACK_CANDIDATES, primary_result['glyph_indices'], fallback_result['glyph_indices']):
121 if primary_glyph == 0 and fallback_glyph != 0:
122 return {'family': fallback, 'codepoint': f'U+{cp:04X}', 'text': chr(cp),
123 'primary_file': primary_result['file'], 'fallback_file': fallback_result['file'],
124 'fallback_glyph': fallback_glyph}
125 return None
126
127
128 def glyph_signature(rig, pane_id, text):
129 rig.focus(pane_id)
130 specimen = '\\033[?25l\\033[2J\\033[H' + text + '\\033[3;1HFALLBACK-READY'
131 rig.shell("printf '%b' " + shlex.quote(specimen))
132 rig.wait_state(lambda s: 'FALLBACK-READY' in by_id(s)[pane_id]['painted_text'])
133 state = rig.state()
134 pane = by_id(state)[pane_id]['content']
135 pixels = crop(rig.last_pixels(), {'x': pane['x'], 'y': pane['y'],
136 'w': state['cell_w'] * 2, 'h': state['cell_h']})
137 return hashlib.sha256(pixels).hexdigest(), any(value != 16 for value in pixels)
138
139
98 def check_icons(rig, refs): 140 def check_icons(rig, refs):
99 for pane_id in refs: 141 for pane_id in refs:
100 rig.focus(pane_id) 142 rig.focus(pane_id)
@@ -111,7 +153,7 @@ def check_icons(rig, refs):
111 require(all(any(v != 16 for v in shape) for shape in shapes[:3]), 'blank icon') 153 require(all(any(v != 16 for v in shape) for shape in shapes[:3]), 'blank icon')
112 154
113 155
114 def invalid_configs(rig): 156 def invalid_configs(rig, family):
115 cases = [('font-size = nan\n', '1'), ('# first\nfont-size = 0\n', '2'), 157 cases = [('font-size = nan\n', '1'), ('# first\nfont-size = 0\n', '2'),
116 ('font-size = 193\n', '1'), ('font-famly = monospace\n', '1'), 158 ('font-size = 193\n', '1'), ('font-famly = monospace\n', '1'),
117 ('font-family = "unterminated\n', '1'), ('font-size 12\n', '1')] 159 ('font-family = "unterminated\n', '1'), ('font-size 12\n', '1')]
@@ -128,6 +170,10 @@ def invalid_configs(rig):
128 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5) 170 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
129 require(result.returncode == 2 and 'MuxDefinitelyMissingFamily012345' in result.stderr, 171 require(result.returncode == 2 and 'MuxDefinitelyMissingFamily012345' in result.stderr,
130 'missing family silently substituted or lacked actionable diagnostic') 172 'missing family silently substituted or lacked actionable diagnostic')
173 write_config(rig, 'font-family = "' + family + '"\nfont-family = MuxMissingFallback012345\n')
174 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
175 require(result.returncode == 2 and 'MuxMissingFallback012345' in result.stderr,
176 'missing secondary family was skipped or misdiagnosed')
131 match = subprocess.check_output(['fc-match', '-f', '%{family[0]}\n%{spacing}', 'sans-serif'], text=True).split('\n') 177 match = subprocess.check_output(['fc-match', '-f', '%{family[0]}\n%{spacing}', 'sans-serif'], text=True).split('\n')
132 if match[1] in ('', '0'): 178 if match[1] in ('', '0'):
133 write_config(rig, 'font-family = ' + match[0] + '\n') 179 write_config(rig, 'font-family = ' + match[0] + '\n')
@@ -155,6 +201,8 @@ def main():
155 rig = LifecycleRig(args.mux, args.muxg) 201 rig = LifecycleRig(args.mux, args.muxg)
156 rig.env.pop('MUXG_TEST_THEME', None) 202 rig.env.pop('MUXG_TEST_THEME', None)
157 report = {'family': args.family} 203 report = {'family': args.family}
204 fallback = fallback_oracle(args.family)
205 fallback_family = fallback['family'] if fallback else args.family
158 original_scale = None 206 original_scale = None
159 def scale(value): 207 def scale(value):
160 response = subprocess.run(['swaymsg', '-r', 'output', args.output, 'scale', str(value)], 208 response = subprocess.run(['swaymsg', '-r', 'output', args.output, 'scale', str(value)],
@@ -182,12 +230,13 @@ def main():
182 rig.shell('printf %s "$$" > ' + shlex.quote(str(path))) 230 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
183 eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written') 231 eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written')
184 shell_files[pane_id] = (path, path.read_text()) 232 shell_files[pane_id] = (path, path.read_text())
185 config = 'font-family = "' + args.family + '"\nfont-size = 12.4\n' 233 config = ('font-family = "' + args.family + '"\nfont-family = "' +
234 fallback_family + '"\nfont-size = 12.4\n')
186 write_config(rig, config) 235 write_config(rig, config)
187 paint(rig, refs) 236 paint(rig, refs)
188 require(signature(rig) == baseline, 'config changed the already running client') 237 require(signature(rig) == baseline, 'config changed the already running client')
189 rig.quit() 238 rig.quit()
190 invalid_configs(rig) 239 invalid_configs(rig, args.family)
191 write_config(rig, config) 240 write_config(rig, config)
192 rig.launch_gui([], 'gui-config-font') 241 rig.launch_gui([], 'gui-config-font')
193 paint(rig, refs, args.nerd) 242 paint(rig, refs, args.nerd)
@@ -204,12 +253,25 @@ def main():
204 check_icons(rig, refs) 253 check_icons(rig, refs)
205 rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes') 254 rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes')
206 rig.quit() 255 rig.quit()
207 # Explicit family and size must override conflicting valid config. 256 # Explicit CLI families replace the complete conflicting config chain.
208 write_config(rig, 'font-family = monospace\nfont-size = 18\n') 257 write_config(rig, 'font-family = monospace\nfont-family = MuxIgnoredFallback012345\nfont-size = 18\n')
209 rig.launch_gui(['--font-family', args.family, '--font-size', '12.4'], 'gui-cli-font') 258 cli_families = ['--font-family', args.family, '--font-family', fallback_family]
259 rig.launch_gui([*cli_families, '--font-size', '12.4'], 'gui-cli-font')
210 paint(rig, refs, args.nerd) 260 paint(rig, refs, args.nerd)
211 require(signature(rig) == configured, 'CLI did not override both config font settings') 261 require(signature(rig) == configured, 'CLI did not override both config font settings')
262 fallback_render = glyph_signature(rig, next(iter(refs)), fallback['text']) if fallback else None
212 rig.quit() 263 rig.quit()
264 if fallback:
265 rig.launch_gui(['--font-family', args.family, '--font-size', '12.4'],
266 'gui-cli-primary-font')
267 primary_render = glyph_signature(rig, next(iter(refs)), fallback['text'])
268 require(fallback_render[1], 'fallback glyph rendered blank')
269 require(fallback_render[0] != primary_render[0],
270 'configured fallback matched the primary missing-glyph raster')
271 fallback.update(fallback_raster=fallback_render[0], primary_raster=primary_render[0])
272 report['fallback_oracle'] = fallback
273 rig.ok('Adwaita fallback glyph differs from the primary .notdef raster')
274 rig.quit()
213 # Record the independently calculated raster size; fractional rounding is also unit-tested. 275 # Record the independently calculated raster size; fractional rounding is also unit-tested.
214 display_scale = 2 if args.output else 1 276 display_scale = 2 if args.output else 1
215 base_dpi = 72 if sys.platform == 'darwin' else 96 277 base_dpi = 72 if sys.platform == 'darwin' else 96