86d04514
feat: add native font fallbacks
a73x 2026-09-07 10:57
Commit message
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 |