a73x

e9c4a928

feat: configure native fonts and preserve them across display scales

a73x   2026-09-06 05:19

Commit message
feat: configure native fonts and preserve them across display scales

README.md
Old New
@@ -54,9 +54,32 @@ handoff, including that handoff's own remote-start behavior. `--via` uses a
54 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`. 54 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
55 Closing the window detaches all panes; their sessions stay on their daemons. When 55 Closing the window detaches all panes; their sessions stay on their daemons. When
56 a shell exits, its pane shows the exit status and the window remains open. 56 a shell exits, its pane shows the exit status and the window remains open.
57 `--font-px` sets the font size 57 `muxg` reads `$XDG_CONFIG_HOME/mux/config`, or `~/.config/mux/config` when
58 at 100% display scale (default 16); glyphs are rasterized at the monitor's 58 that variable is unset or empty. For example, with this font installed:
59 actual scale and refreshed when the window moves between displays. Linux 59
60 ```ini
61 font-family = "CommitMono Nerd Font Mono"
62 font-size = 12.4
63 ```
64
65 The supported [Ghostty-style settings](https://ghostty.org/docs/config/reference#font-size)
66 are one installed monospace `font-family` and a `font-size` from 1–192 points.
67 Fractional sizes are preserved until rasterization: points × 96/72 on Linux
68 (points × 1 on macOS), then display scale, rounded to a pixel. Blank lines and
69 full-line `#` comments are accepted; a family may be double quoted. Duplicate
70 keys, unknown keys, malformed values and unavailable families produce diagnostics.
71 Repeated fallback families, per-style families, escapes and inline comments are
72 not supported. This is a font-settings subset, not a full Ghostty config reader.
73
74 `--font-family NAME` and `--font-size POINTS` override the config. The existing
75 `--font-px N` (1–256 pixels at 100% scale) also overrides config sizing; choose
76 one size flag. Without config the original font (monospace, 16 logical pixels)
77 and colors remain. Close and reopen the client to apply config edits; daemon
78 sessions survive. Glyphs refresh automatically when display scale changes,
79 retaining the selected family. Installed Nerd Font Mono icons use that face;
80 font fallback and cross-cell programming ligatures are not implemented.
81 Ghostty theme file loading is the next appearance slice.
82 Linux
60 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER` 83 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER`
61 or `SDL_VIDEODRIVER` setting overrides that preference. 84 or `SDL_VIDEODRIVER` setting overrides that preference.
62 `kill -USR1 PID` prints the frame timing table to stderr, as does exit. 85 `kill -USR1 PID` prints the frame timing table to stderr, as does exit.
RETRO.md
Old New
@@ -851,3 +851,47 @@ Native source is unchanged, so existing functional validation applies.
851 appearance plan. The user authorised sharing sprint pages/assets on their 851 appearance plan. The user authorised sharing sprint pages/assets on their
852 own tailnet as the standing handoff. The recorded demo was then explicitly 852 own tailnet as the standing handoff. The recorded demo was then explicitly
853 approved (“I approve the demo! Looks good.”). NVIDIA timing remains separate. 853 approved (“I approve the demo! Looks good.”). NVIDIA timing remains separate.
854
855 ## Native appearance: config and fonts — 2026-09-05
856
857 Delivered font-family and fractional font-size config with explicit CLI precedence,
858 restart application, legacy defaults, and installed Nerd Font Mono rendering.
859 The family remains in use through DPI cache rebuilds; no hot reload, fallback,
860 color-file loading or ligatures were added. The recorded demo awaits user review.
861
862 Opening cleanup found the font/cache boundary ready. Closing cleanup removed
863 early rounding and redundant parser state, rejected malformed/duplicate config,
864 and made font matching verify the actual loaded face. The Luna/Terra pair found
865 lifetime, precedence and diagnostic problems; root integration caught compile
866 errors and a Fontconfig spacing trap that agreement alone had missed. Fontconfig
867 can echo a requested monospace property for a proportional face. FreeType's
868 fixed-width flag is also absent on some genuine monospace faces (Noto Sans Mono);
869 final validation uses that flag or equal unscaled ASCII advances. Native baseline
870 units and an actual installed proportional family exposed both mistakes.
871
872 Final native build/units, native integration, full CI, offscreen and NVIDIA font
873 acceptance passed. Three panes on two daemons kept shell PIDs and kernel PTY sizes
874 across restarts; raster signatures and a font-file glyph oracle verified Nerd Font
875 icons and scaling. NVIDIA stress passed with default/configured font final frame
876 p99 19,473 / 18,825 us; input upper bounds 70.3 / 55.5 ms. Tests were separate from
877 recording. Prior failed NVIDIA timings remain retained, not retroactively passed;
878 no physical display or new macOS validation is claimed.
879
880 The 33.2-second actual GUI demo has two documented restart cuts, includes the
881 invalid-config failure path, and is embedded in the private review webpage. Its
882 first error-example attempt inherited the daemon shell's separate config path;
883 the recorder now passes the intended XDG_CONFIG_HOME explicitly to the nested
884 client. Existing private routes were preserved. Artifacts and model-verified
885 cumulative agent counters (cached input included, not incremental cost) live in
886 `dist/appearance-slice2/`; the appearance plan lists the evidence and teardown.
887
888 - [x] Restore legacy defaults and explicitly select the trial palette in fixtures.
889 - [x] Verify installed Nerd Font Mono, real restart survival and selected-family DPI.
890 - [x] Keep the webpage/video as the primary private handoff.
891 - [ ] Record slice 2 demo acceptance separately from tests; then begin theme files.
892 - [ ] Theme slice: extend the strict parser intentionally and document the supported
893 Ghostty subset, defaults → theme → config → CLI precedence, and missing/default
894 behavior. Keep font-family lifetime and late raster rounding intact.
895 - [ ] Renderer follow-up retains the earlier intermittent NVIDIA budget miss and
896 offscreen surface-growth readback issue; a passing run does not settle either.
897 - [ ] Discuss ligatures only after the preceding appearance slices.
build.zig
Old New
@@ -1200,8 +1200,12 @@ pub fn build(b: *std.Build) void {
1200 native_theme.addArtifactArg(mux_exe); 1200 native_theme.addArtifactArg(mux_exe);
1201 native_theme.addArtifactArg(muxg_exe); 1201 native_theme.addArtifactArg(muxg_exe);
1202 native_theme.step.dependOn(&native_lifecycle.step); 1202 native_theme.step.dependOn(&native_lifecycle.step);
1203 const native_fonts = b.addSystemCommand(&.{ "python3", "-B", "test/native_fonts.py" });
1204 native_fonts.addArtifactArg(mux_exe);
1205 native_fonts.addArtifactArg(muxg_exe);
1206 native_fonts.step.dependOn(&native_theme.step);
1203 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1207 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1204 native_e2e_step.dependOn(&native_theme.step); 1208 native_e2e_step.dependOn(&native_fonts.step);
1205 1209
1206 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale 1210 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale
1207 // Debug daemon gives misleading latency numbers under raw terminal output. 1211 // Debug daemon gives misleading latency numbers under raw terminal output.
docs/demos/native-appearance-slice2.html
Old New
@@ -0,0 +1,67 @@
1 <!doctype html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <title>Native appearance · slice 2</title>
7 <style>
8 :root { color-scheme:dark; --ink:#f3e8d0; --muted:#c4aa8d; --bg:#211b1a; --panel:#2b211f; --panel2:#342824; --teal:#2f6f70; --amber:#d8a84e; --line:#76594b; }
9 * { box-sizing:border-box; } body { margin:0; min-height:100vh; background:radial-gradient(circle at 78% 0,#3b2922 0,transparent 42%),var(--bg); color:var(--ink); font:16px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
10 main { width:min(1080px,calc(100% - 40px)); margin:auto; padding:58px 0 52px; } .eyebrow { color:var(--amber); font-size:.76rem; font-weight:750; letter-spacing:.16em; text-transform:uppercase; }
11 h1 { max-width:760px; margin:10px 0 14px; font-size:clamp(2.2rem,6vw,4.5rem); line-height:1.02; letter-spacing:-.055em; } .lede { max-width:700px; margin:0 0 30px; color:var(--muted); font-size:1.15rem; }
12 .status { display:inline-flex; align-items:center; gap:9px; padding:8px 13px; border:1px solid #8b6a4d; border-radius:999px; background:#372821; color:#ffd995; font-size:.86rem; } .status i { width:8px; height:8px; border-radius:50%; background:var(--amber); box-shadow:0 0 0 4px #d8a84e22; }
13 .video-card { margin:42px 0 0; padding:14px; border:1px solid #76594b99; border-radius:20px; background:#171313aa; box-shadow:0 22px 70px #09070766; } video { display:block; width:100%; height:auto; aspect-ratio:1100/700; border-radius:12px; background:#100d0d; }
14 figcaption { display:flex; flex-wrap:wrap; justify-content:space-between; gap:8px 20px; padding:13px 4px 1px; color:var(--muted); font-size:.9rem; } a { color:#a8d6c6; text-underline-offset:3px; }
15 section { margin-top:52px; } h2 { margin:0 0 16px; font-size:1.45rem; letter-spacing:-.02em; } .grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }
16 article { padding:21px; border:1px solid #76594b66; border-radius:14px; background:linear-gradient(145deg,#342824cc,#2b211fcc); } article h3 { margin:0 0 7px; font-size:1rem; color:#ffe6b6; } article p { margin:0; color:var(--muted); }
17 pre { overflow:auto; margin:0; padding:18px 20px; border:1px solid #76594b66; border-radius:14px; background:#171313cc; color:#ffe6b6; } .review { border-left:3px solid var(--amber); padding:4px 0 4px 17px; color:#ead9bd; } .review p { margin:0; }
18 footer { margin-top:52px; color:#9d8775; font-size:.82rem; } @media (max-width:650px) { main { width:calc(100% - 26px); padding-top:36px; } .grid { grid-template-columns:1fr; } figcaption { display:block; } figcaption span { display:block; margin-top:5px; } }
19 </style>
20 </head>
21 <body>
22 <main>
23 <div class="eyebrow">muxg · native appearance</div>
24 <h1>Fonts and config for muxg</h1>
25 <p class="lede">Slice 2 gives the native client a font config file. It keeps the original legacy appearance by default while making font family and point size configurable.</p>
26 <div class="status"><i aria-hidden="true"></i>Demo awaiting your review</div>
27
28 <figure class="video-card">
29 <video controls playsinline preload="metadata" poster="preview.png" width="1100" height="700" aria-label="Native appearance slice 2 demo">
30 <source src="demo.mp4" type="video/mp4">
31 Your browser cannot play this video. <a href="demo.mp4">Download the demo video</a>.
32 </video>
33 <figcaption><span>33.2 seconds · 1100 × 700 · NVIDIA · isolated headless Sway at 200%</span><span>No audio · restart cuts at 10.2s and 25.8s · <a href="demo.mp4">direct video</a></span></figcaption>
34 </figure>
35
36 <section>
37 <h2>What this slice achieves</h2>
38 <div class="grid">
39 <article><h3>Strict, small config</h3><p>Config is read from <code>$XDG_CONFIG_HOME/mux/config</code>, falling back to <code>~/.config/mux/config</code>. Unknown keys and malformed values identify the file and line. Unavailable fonts identify the requested family. Each key appears once.</p></article>
40 <article><h3>Predictable precedence</h3><p>CLI family and size options override their matching config values. Legacy <code>--font-px</code> remains available and is rejected when combined with the decimal point size flag.</p></article>
41 <article><h3>Ghostty-style points</h3><p><code>font-family = &quot;CommitMono Nerd Font Mono&quot;</code> and <code>font-size = 12.4</code> are the demo configuration. Fractions survive display scaling and the selected family stays in use when display scale changes.</p></article>
42 <article><h3>Font coverage</h3><p>Regular, bold, italic, bold italic, and icon glyphs use the selected family. Restart the client to apply config edits. Font fallback and programming ligatures remain separate work.</p></article>
43 </div>
44 </section>
45
46 <section>
47 <h2>Configuration used in the demo</h2>
48 <pre><code># $XDG_CONFIG_HOME/mux/config
49 font-family = "CommitMono Nerd Font Mono"
50 font-size = 12.4</code></pre>
51 <p style="margin:14px 0 0;color:#c4aa8d">Changing this file takes effect on the next launch. The demo restarts muxg while keeping its sessions, then exercises CLI font overrides.</p>
52 </section>
53
54 <section>
55 <h2>What to review</h2>
56 <div class="review"><p>Please review family choice, text size, readability, restart behavior, and the clarity of diagnostics. With no config, the original colors remain selected. Ghostty theme-file loading is the next agreed slice; ligatures remain a later discussion.</p></div>
57 </section>
58
59 <section>
60 <h2>Validation status</h2>
61 <p style="margin:0;color:#c4aa8d">CI, native units and the full native integration suite passed. Config, CLI overrides, shell survival, Nerd Font icons and 200% → 100% → 150% → 200% scale changes passed on NVIDIA Wayland. Both NVIDIA stress runs passed: default-font frame p99 19.47 ms and configured Nerd Font 18.83 ms (20 ms limit); sampled input-to-painted upper bounds were 70.3 ms and 55.5 ms (250 ms limit). Frame timing includes retained-frame readback and vsync. Earlier NVIDIA failures remain recorded; these runs do not establish a performance improvement. No physical-display or macOS validation is claimed.</p>
62 </section>
63
64 <footer>Native appearance slice 2 · review artifact</footer>
65 </main>
66 </body>
67 </html>
docs/superpowers/plans/2026-09-05-native-appearance.md
Old New
@@ -9,7 +9,7 @@ daemon sessions. Terminal CLI and browser appearance are outside this work.
9 9
10 1. **Theme the existing native UI with a hardcoded palette.** Establish one 10 1. **Theme the existing native UI with a hardcoded palette.** Establish one
11 reusable theme value and demonstrate a visibly different, cohesive appearance 11 reusable theme value and demonstrate a visibly different, cohesive appearance
12 before building configuration. This is the active sprint. 12 before building configuration. Recorded demo approved on 2026-09-05.
13 2. **Config and fonts.** Read `$XDG_CONFIG_HOME/mux/config`, falling back to 13 2. **Config and fonts.** Read `$XDG_CONFIG_HOME/mux/config`, falling back to
14 `~/.config/mux/config`; support Ghostty-compatible font-family and font-size 14 `~/.config/mux/config`; support Ghostty-compatible font-family and font-size
15 (points), explicit CLI precedence, actionable diagnostics, and installed 15 (points), explicit CLI precedence, actionable diagnostics, and installed
@@ -27,7 +27,7 @@ daemon sessions. Terminal CLI and browser appearance are outside this work.
27 Each slice ends with validated work and an actual application demo. User demo 27 Each slice ends with validated work and an actual application demo. User demo
28 acceptance is recorded separately from implementation and test results. 28 acceptance is recorded separately from implementation and test results.
29 29
30 ## Active sprint: hardcoded theme 30 ## Slice 1: hardcoded theme (recorded demo approved)
31 31
32 Use a warm dark trial theme with cream text, teal focus and amber bell feedback. 32 Use a warm dark trial theme with cream text, teal focus and amber bell feedback.
33 The original theme remains a named value for the later no-config default. The 33 The original theme remains a named value for the later no-config default. The
@@ -183,3 +183,120 @@ NVIDIA stress limit. Config/fonts is the next authorised slice; Ghostty theme
183 file loading follows it. When adding config, restore `theme.legacy` as the no-config default and 183 file loading follows it. When adding config, restore `theme.legacy` as the no-config default and
184 make the theme probe explicitly select its trial fixture. Discuss ligatures only 184 make the theme probe explicitly select its trial fixture. Discuss ligatures only
185 after the preceding slices, as the user requested. 185 after the preceding slices, as the user requested.
186
187 ## Active sprint: config and fonts
188
189 Goal: choose an installed font family and point size in mux's config, restart
190 the native client, and continue the same sessions with the new typography.
191 Ghostty theme file loading follows in slice 3; ligatures remain a later discussion.
192 Opening inspection found the existing font/cache boundary ready for the change;
193 no separate preliminary refactor is needed.
194
195 Observable acceptance:
196
197 - Read `$XDG_CONFIG_HOME/mux/config`, or `~/.config/mux/config` when unset.
198 Missing configuration keeps the original 16 logical-pixel font and legacy
199 theme. Unsupported keys and malformed values name the file and line.
200 - Accept `font-family` and fractional `font-size` points. Explicit CLI family
201 and size override config; existing `--font-px` remains supported. Explain
202 conflicting size flags and missing families without silently substituting.
203 - Preserve fractional sizing until final raster rounding and preserve the family
204 at 200%, 100%, 150%, and back to 200% display scale. Match Ghostty's nominal
205 point conversion (96 DPI on Linux, 72 on macOS), then apply SDL display scale.
206 - Demonstrate regular, bold, italic, and installed Nerd Font Mono icons in real
207 output across multiple panes; check pixels and independent PTY dimensions.
208 - Edit the config while running: the existing client keeps its font. Restart it:
209 saved panes, shell identities, and session output survive with the new font.
210 - Finish with required checks, adversarial review, closing cleanup, and a private
211 review webpage with actual application video. Record acceptance separately.
212
213 Luna owns product/config/font code and focused units. Terra independently reviews
214 and sends concrete findings to Luna. Root owns integration scenarios, validation,
215 documentation, demo, webpage, and commits. Carry forward the unresolved NVIDIA
216 frame-budget result; do not count earlier appearance acceptance as a waiver.
217
218 ### Slice 2 implementation and validation record
219
220 Config/fonts implementation, independent review and final functional validation
221 are complete. The recorded demo awaits user acceptance.
222 The parser supports one font-family and one font-size entry, optional family
223 quotes, blank lines and full-line comments. Duplicate keys are rejected. Missing
224 files use defaults; parse errors include the path and line, and font matching
225 errors name the requested family. CLI values override the matching config values,
226 including the original --font-px size flag.
227
228 Closing review removed early point rounding and redundant parser state, added
229 actual face validation after Fontconfig matching, and kept the font family through
230 cache rebuilds. The trial palette is now an explicit integration fixture gated
231 by MUXG_TEST_FIFO; regular launches use the original palette. No color-config
232 interface, font fallback, or ligature work was added.
233
234 Independent negative testing exposed Fontconfig copying a requested spacing into
235 a proportional match. The first FreeType fix rejected Noto Sans Mono because it
236 omits the fixed-width flag; baseline native units caught that. The final check
237 accepts that flag or equal unscaled printable-ASCII advances. It rejects an actual
238 installed proportional family without relying on small-size pixel rounding.
239 These observations and earlier compile errors are retained in the build logs.
240
241 The first diagnostic recording attempt used a nested client in a daemon shell,
242 whose deliberately isolated config differed from the outer GUI config. The final
243 recorder passes the intended XDG_CONFIG_HOME explicitly. This was a fixture error.
244
245 Artifacts live under `dist/appearance-slice2/`. Both binaries were built together
246 in ReleaseSafe with pinned Zig. The compositor log identifies NVIDIA RTX 3080;
247 its owned HEADLESS-1 output uses 2200×1400 at 200%. No physical output was changed.
248 No new macOS validation is claimed. Existing NVIDIA frame-budget misses remain
249 historical failed checks and a renderer/validation follow-up, irrespective of
250 the current font demo or any passing run.
251
252 Final evidence:
253
254 - `build-face-final.log`: native build, font/painter units and native core passed.
255 `ci.log`: full CI passed, including terminal integration, agent and throughput.
256 - `native-e2e-final.log`: full viewer, tiling, picker, resize, lifecycle, theme
257 and new font acceptance passed after the final FreeType fix.
258 - `fonts-offscreen-final.log` / `fonts-wayland-final.log`: config and CLI
259 precedence, malformed config and proportional-family refusal, HOME fallback,
260 three saved panes on two daemons, shell PID/PTY survival, independently queried
261 kernel dimensions, actual glyph rasters and distinct Nerd Font icons passed.
262 The font-file oracle verifies nonzero distinct FreeType glyph IDs. NVIDIA
263 200% → 100% → 150% → 200% retains the selected family and glyphs. Result JSON
264 copies retain font path, expected raster size and raster signatures.
265 - `scale-wayland.log`: existing drag cancellation, nested resize, kernel PTYs and
266 pixels passed; the final face fix was separately exercised by the font scale
267 scenario above.
268 - `stress-results.json`: both NVIDIA raw-output runs passed on final ReleaseSafe
269 binaries with CI, other fixtures and recording stopped. Default before/final
270 frame p99: 18,982 / 19,473 us; configured CommitMono 12.4pt: 18,405 / 18,825 us.
271 Input-to-painted sampled upper bounds: 70.3 / 55.5 ms, with 5 ms polling.
272 The unchanged frame budget is 20,000 us and input budget 250 ms. The frame
273 metric includes readback and vsync. This is current-build acceptance evidence
274 on headless NVIDIA, not a performance improvement or physical-display claim.
275
276 The demo is 33.2 seconds, H.264/yuv420p at 1100×700, recorded from actual NVIDIA
277 Wayland windows at 200%, without audio. Two explicit restart cuts occur at 10.2s
278 and 25.8s; each segment is continuous. It shows original defaults, config saved
279 without reloading the existing client, CommitMono icons/styles, retained panes
280 and typing, the picker, bad-config diagnostics, and a temporary CLI size override.
281 The recording and scripts are retained in `dist/appearance-slice2/`. The review
282 page source is `docs/demos/native-appearance-slice2.html`.
283
284 Private handoff: https://charizard.folk-amberjack.ts.net/appearance-slice2/
285 The standing Tailscale authorization applies. Only this route is added; slice 1
286 remains available. The allowlisted loopback server is `127.0.0.1:18772`; its PID is
287 in `dist/appearance-slice2/page-server.pid`. Teardown removes only this route with
288 `tailscale serve --https=443 --set-path /appearance-slice2 off`, then stops that
289 owned server. Browser and HTTPS evidence are retained alongside the recording.
290
291 Next: record the user's demo review, then deliver Ghostty theme loading. Do not
292 start ligature implementation without the agreed later discussion.
293
294 Review-page verification passed: private HTTPS canonical URL and 206 byte ranges
295 for video/preview, with the prior slice still available. Chromium loaded the
296 33.2-second 1100×700 video, sought to 12 seconds and played beyond 12.5 seconds
297 using a simulated user gesture. Desktop 1280px and mobile 390px layouts had no
298 horizontal overflow; root inspected the mobile page and the decoded diagnostic
299 frame. The initial browser probe omitted a user gesture and autoplay policy
300 correctly refused playback; the page uses manual controls. `check-final.log`
301 records the precommit gate. Owned compositor/browser fixtures were stopped; the
302 allowlisted review-page server remains running.
src/cli/muxg.zig
Old New
@@ -10,8 +10,17 @@ const xdg = @import("xdg");
10 const proto = term.protocol; 10 const proto = term.protocol;
11 const hosts = client.hosts; 11 const hosts = client.hosts;
12 12
13 const PointSize = struct {
14 value: f64,
15 pub fn parseCLI(text: []const u8) !PointSize {
16 const value = std.fmt.parseFloat(f64, text) catch return error.Invalid;
17 if (!std.math.isFinite(value) or value < 1 or value > 192) return error.Invalid;
18 return .{ .value = value };
19 }
20 };
21
13 const usage = 22 const usage =
14 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] 23 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-family FAMILY] [--font-size POINTS] [--font-px N]
15 \\ 24 \\
16 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace 25 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace
17 \\ --session the session name (default: the daemon's default session) 26 \\ --session the session name (default: the daemon's default session)
@@ -19,6 +28,8 @@ const usage =
19 \\ --via a command whose stdio is the daemon 28 \\ --via a command whose stdio is the daemon
20 \\ --key the QUIC key file (or MUX_KEY_FILE) 29 \\ --key the QUIC key file (or MUX_KEY_FILE)
21 \\ --font-px font pixels at 100% display scale (default 16) 30 \\ --font-px font pixels at 100% display scale (default 16)
31 \\ --font-family font family (config: font-family)
32 \\ --font-size font size in points, 1–192 (config: font-size)
22 \\ --help --version 33 \\ --help --version
23 \\ 34 \\
24 ; 35 ;
@@ -28,7 +39,9 @@ const Arguments = struct {
28 via: ?[]const u8 = null, 39 via: ?[]const u8 = null,
29 key: ?[]const u8 = null, 40 key: ?[]const u8 = null,
30 session: ?proto.SessionName = null, 41 session: ?proto.SessionName = null,
31 font_px: u16 = 16, 42 font_px: ?u16 = null,
43 font_family: ?[]const u8 = null,
44 font_size: ?PointSize = null,
32 _target: ?[]const u8 = null, 45 _target: ?[]const u8 = null,
33 _targets: usize = 0, 46 _targets: usize = 0,
34 47
@@ -55,12 +68,36 @@ pub fn main() !u8 {
55 68
56 var o: Arguments = .{}; 69 var o: Arguments = .{};
57 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) })); 70 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) }));
71 if (o.font_px != null and o.font_size != null) {
72 std.debug.print("muxg: --font-px and --font-size are ambiguous; choose one\n", .{});
73 return 2;
74 }
75 var settings: native.config.Settings = .{};
76 var config_line: usize = 1;
77 const config_path = xdg.pathFrom(argv_alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"), ".config", "config") catch |err| switch (err) {
78 error.NoHome => null,
79 else => return err,
80 };
81 if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| {
82 const reason = switch (err) {
83 error.UnknownKey => "unknown key; supported keys: font-family, font-size",
84 error.InvalidSyntax => "expected one key = value per line, without duplicate keys",
85 error.InvalidValue => "font-size must be a finite number between 1 and 192 points",
86 error.MissingFamily => "font-family must name an installed monospace family",
87 else => @errorName(err),
88 };
89 std.debug.print("muxg: config {s}:{d}: {s}\n", .{ path, config_line, reason });
90 return 2;
91 };
58 const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets; 92 const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets;
59 if (named > 1) { 93 if (named > 1) {
60 std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); 94 std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage});
61 return 2; 95 return 2;
62 } 96 }
63 if (o.font_px == 0 or o.font_px > 256) { 97 const cli_points: ?f64 = if (o.font_size) |points| points.value else null;
98 const font_points = if (o.font_px != null) null else cli_points orelse settings.size_points;
99 const font_px = o.font_px orelse 16;
100 if (font_px == 0 or font_px > 256) {
64 std.debug.print("muxg: --font-px must be between 1 and 256\n", .{}); 101 std.debug.print("muxg: --font-px must be between 1 and 256\n", .{});
65 return 2; 102 return 2;
66 } 103 }
@@ -80,7 +117,9 @@ pub fn main() !u8 {
80 .state_path = if (temporary) null else try xdg.statePath(argv_alloc, "native-workspace.json"), 117 .state_path = if (temporary) null else try xdg.statePath(argv_alloc, "native-workspace.json"),
81 .key_path = o.key orelse key, 118 .key_path = o.key orelse key,
82 .session = session, 119 .session = session,
83 .font_px = o.font_px, 120 .font_px = font_px,
121 .font_family = o.font_family orelse settings.family orelse "monospace",
122 .font_points = font_points,
84 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 123 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
85 }) catch |err| { 124 }) catch |err| {
86 std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)}); 125 std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)});
src/gui/config.zig
Old New
@@ -0,0 +1,94 @@
1 //! Small, explicit native font configuration parser.
2 const std = @import("std");
3
4 pub const Settings = struct {
5 family: ?[:0]const u8 = null,
6 size_points: ?f64 = null,
7 };
8 pub const Error = error{ InvalidSyntax, UnknownKey, InvalidValue, MissingFamily, OutOfMemory };
9
10 // 192pt is the largest accepted size: at Linux's 96 DPI it is 256px at 100%.
11
12 fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 {
13 var value = raw;
14 if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1];
15 if (std.mem.indexOfScalar(u8, value, '"') != null or
16 std.mem.indexOfScalar(u8, value, 0) != null or
17 !std.unicode.utf8ValidateSlice(value)) return error.InvalidSyntax;
18 if (std.mem.trim(u8, value, " \t").len == 0) return error.MissingFamily;
19 return alloc.dupeZ(u8, value);
20 }
21
22 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Error!Settings {
23 var out: Settings = .{};
24 errdefer if (out.family) |f| alloc.free(f);
25 var it = std.mem.splitScalar(u8, bytes, '\n');
26 var line_no: usize = 0;
27 while (it.next()) |raw| {
28 line_no += 1;
29 if (line_out) |p| p.* = line_no;
30 const line = std.mem.trim(u8, raw, " \t\r");
31 if (line.len == 0 or line[0] == '#') continue;
32 const eq = std.mem.indexOfScalar(u8, line, '=') orelse {
33 return error.InvalidSyntax;
34 };
35 const key = std.mem.trim(u8, line[0..eq], " \t");
36 const value = std.mem.trim(u8, line[eq + 1 ..], " \t");
37 if (key.len == 0 or value.len == 0) {
38 return error.InvalidSyntax;
39 }
40 if (std.mem.eql(u8, key, "font-family")) {
41 if (out.family != null) {
42 return error.InvalidSyntax;
43 }
44 out.family = try valueText(value, alloc);
45 if (out.family.?.len == 0) {
46 return error.MissingFamily;
47 }
48 } else if (std.mem.eql(u8, key, "font-size")) {
49 if (out.size_points != null) {
50 return error.InvalidSyntax;
51 }
52 const points = std.fmt.parseFloat(f64, value) catch {
53 return error.InvalidValue;
54 };
55 if (!std.math.isFinite(points) or points < 1 or points > 192) {
56 return error.InvalidValue;
57 }
58 out.size_points = points;
59 } else {
60 return error.UnknownKey;
61 }
62 }
63 return out;
64 }
65
66 pub fn load(alloc: std.mem.Allocator, path: []const u8, line_out: ?*usize) !Settings {
67 const file = std.fs.cwd().openFile(path, .{}) catch |err| if (err == error.FileNotFound) return .{} else return err;
68 defer file.close();
69 const bytes = try file.readToEndAlloc(alloc, 64 * 1024);
70 defer alloc.free(bytes);
71 return parse(alloc, bytes, line_out);
72 }
73
74 test "config parses comments quotes and decimal points" {
75 const s = try parse(std.testing.allocator, "# native\nfont-family = \"Noto Sans Mono\"\nfont-size = 12.5\n", null);
76 defer std.testing.allocator.free(s.family.?);
77 try std.testing.expectEqualStrings("Noto Sans Mono", s.family.?);
78 try std.testing.expectEqual(@as(f64, 12.5), s.size_points.?);
79 try std.testing.expectError(error.UnknownKey, parse(std.testing.allocator, "font-weight = bold\n", null));
80 try std.testing.expectError(error.InvalidValue, parse(std.testing.allocator, "font-size = nan\n", null));
81 }
82
83 test "config refuses duplicates and malformed text at the actual line" {
84 const a = std.testing.allocator;
85 var line: usize = 0;
86 try std.testing.expectError(error.InvalidSyntax, parse(a, "# header\nfont-family = \"broken\n", &line));
87 try std.testing.expectEqual(@as(usize, 2), line);
88 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = monospace\nfont-family = second\n", &line));
89 try std.testing.expectEqual(@as(usize, 2), line);
90 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-size = 12\nfont-size = 13\n", &line));
91 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = mono\x00space\n", null));
92 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = \"mono\"space\"\n", null));
93 try std.testing.expectError(error.MissingFamily, parse(a, "font-family = \" \"\n", null));
94 }
src/gui/font.zig
Old New
@@ -19,6 +19,12 @@ pub fn scaledPixels(base: u16, display_scale: f32) u16 {
19 return @intFromFloat(std.math.clamp(px, 1, 1024)); 19 return @intFromFloat(std.math.clamp(px, 1, 1024));
20 } 20 }
21 21
22 pub fn scaledPoints(points: f64, display_scale: f32) u16 {
23 const dpi: f64 = if (@import("builtin").os.tag == .macos) 72 else 96;
24 const scale = if (std.math.isFinite(display_scale) and display_scale > 0) @as(f64, display_scale) else 1;
25 return @intCast(std.math.clamp(@as(i64, @intFromFloat(@round(points * dpi / 72 * scale))), 1, 1024));
26 }
27
22 pub fn atlasWidth(px: u16) u16 { 28 pub fn atlasWidth(px: u16) u16 {
23 return @intCast(@max(@as(u32, px) * 2, 1024)); 29 return @intCast(@max(@as(u32, px) * 2, 1024));
24 } 30 }
@@ -54,10 +60,11 @@ pub const Face = struct {
54 fn handle(self: *Face, v: Variant) *Handle { 60 fn handle(self: *Face, v: Variant) *Handle {
55 return &self.handles[@intFromEnum(v)]; 61 return &self.handles[@intFromEnum(v)];
56 } 62 }
57 fn match(buf: *[std.fs.max_path_bytes]u8, index: *c_int, want_bold: bool, want_italic: bool, synth_bold: *bool, synth_italic: *bool) Error![]const u8 { 63 fn match(buf: *[std.fs.max_path_bytes]u8, index: *c_int, family: [:0]const u8, want_bold: bool, want_italic: bool, synth_bold: *bool, synth_italic: *bool) Error![]const u8 {
58 const pat = c.FcPatternCreate() orelse return error.NoMonospaceFace; 64 const pat = c.FcPatternCreate() orelse return error.NoMonospaceFace;
59 defer c.FcPatternDestroy(pat); 65 defer c.FcPatternDestroy(pat);
60 _ = c.FcPatternAddString(pat, c.FC_FAMILY, "monospace"); 66 _ = c.FcPatternAddString(pat, c.FC_FAMILY, @ptrCast(family.ptr));
67 _ = c.FcPatternAddInteger(pat, c.FC_SPACING, c.FC_MONO);
61 _ = c.FcPatternAddInteger(pat, c.FC_WEIGHT, if (want_bold) c.FC_WEIGHT_BOLD else c.FC_WEIGHT_REGULAR); 68 _ = c.FcPatternAddInteger(pat, c.FC_WEIGHT, if (want_bold) c.FC_WEIGHT_BOLD else c.FC_WEIGHT_REGULAR);
62 _ = c.FcPatternAddInteger(pat, c.FC_SLANT, if (want_italic) c.FC_SLANT_ITALIC else c.FC_SLANT_ROMAN); 69 _ = c.FcPatternAddInteger(pat, c.FC_SLANT, if (want_italic) c.FC_SLANT_ITALIC else c.FC_SLANT_ROMAN);
63 _ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern); 70 _ = c.FcConfigSubstitute(null, pat, c.FcMatchPattern);
@@ -65,6 +72,23 @@ pub const Face = struct {
65 var result: c.FcResult = undefined; 72 var result: c.FcResult = undefined;
66 const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace; 73 const found = c.FcFontMatch(null, pat, &result) orelse return error.NoMonospaceFace;
67 defer c.FcPatternDestroy(found); 74 defer c.FcPatternDestroy(found);
75 // Fontconfig treats family and spacing as preferences, so a successful
76 // match alone could silently replace a typo or select proportional text.
77 var spacing: c_int = 0;
78 if (c.FcPatternGetInteger(found, c.FC_SPACING, 0, &spacing) != c.FcResultMatch or
79 (spacing != c.FC_MONO and spacing != c.FC_CHARCELL)) return error.NoMonospaceFace;
80 if (!std.ascii.eqlIgnoreCase(family, "monospace")) {
81 var matched_family: [*c]c.FcChar8 = null;
82 var family_index: c_int = 0;
83 var exact = false;
84 while (c.FcPatternGetString(found, c.FC_FAMILY, family_index, &matched_family) == c.FcResultMatch) : (family_index += 1) {
85 if (std.ascii.eqlIgnoreCase(family, std.mem.span(@as([*:0]const u8, @ptrCast(matched_family))))) {
86 exact = true;
87 break;
88 }
89 }
90 if (!exact) return error.NoMonospaceFace;
91 }
68 var file: [*c]c.FcChar8 = null; 92 var file: [*c]c.FcChar8 = null;
69 if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace; 93 if (c.FcPatternGetString(found, c.FC_FILE, 0, &file) != c.FcResultMatch) return error.NoMonospaceFace;
70 if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, index) != c.FcResultMatch) index.* = 0; 94 if (c.FcPatternGetInteger(found, c.FC_INDEX, 0, index) != c.FcResultMatch) index.* = 0;
@@ -80,7 +104,28 @@ pub const Face = struct {
80 buf[path.len] = 0; 104 buf[path.len] = 0;
81 return buf[0..path.len]; 105 return buf[0..path.len];
82 } 106 }
107 fn isMonospaced(ft: c.FT_Face) bool {
108 if (ft.*.face_flags & c.FT_FACE_FLAG_FIXED_WIDTH != 0) return true;
109 // Some monospace families (including Noto Sans Mono) omit the fixed
110 // pitch flag. Compare unscaled ASCII advances: pixel hinting at a small
111 // size could otherwise make a proportional face look fixed-width.
112 if (c.FT_Load_Char(ft, 'M', c.FT_LOAD_NO_SCALE) != 0) return false;
113 const advance = ft.*.glyph.*.metrics.horiAdvance;
114 if (advance <= 0) return false;
115 for (32..127) |cp| {
116 if (c.FT_Load_Char(ft, @intCast(cp), c.FT_LOAD_NO_SCALE) != 0 or
117 ft.*.glyph.*.metrics.horiAdvance != advance) return false;
118 }
119 return true;
120 }
83 pub fn open(px: u16) Error!Face { 121 pub fn open(px: u16) Error!Face {
122 return openFamily(px, "monospace");
123 }
124 pub fn openFamily(px: u16, family: []const u8) Error!Face {
125 var family_z: [256:0]u8 = undefined;
126 if (family.len == 0 or family.len >= family_z.len or std.mem.indexOfScalar(u8, family, 0) != null) return error.NoMonospaceFace;
127 @memcpy(family_z[0..family.len], family);
128 family_z[family.len] = 0;
84 if (c.FcInit() == c.FcFalse) return error.NoFontconfig; 129 if (c.FcInit() == c.FcFalse) return error.NoFontconfig;
85 var lib: c.FT_Library = null; 130 var lib: c.FT_Library = null;
86 if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit; 131 if (c.FT_Init_FreeType(&lib) != 0) return error.FreetypeInit;
@@ -99,9 +144,15 @@ pub const Face = struct {
99 var idx: c_int = 0; 144 var idx: c_int = 0;
100 var synth_bold = false; 145 var synth_bold = false;
101 var synth_italic = false; 146 var synth_italic = false;
102 const path = try match(&pathbuf, &idx, bold, italic, &synth_bold, &synth_italic); 147 const path = try match(&pathbuf, &idx, family_z[0..family.len :0], bold, italic, &synth_bold, &synth_italic);
103 var ft: c.FT_Face = null; 148 var ft: c.FT_Face = null;
104 if (c.FT_New_Face(lib, @ptrCast(path.ptr), idx, &ft) != 0) return error.FaceLoad; 149 if (c.FT_New_Face(lib, @ptrCast(path.ptr), idx, &ft) != 0) return error.FaceLoad;
150 // A requested FC_SPACING may be copied into a match whose font did
151 // not declare spacing. Verify the loaded face instead of that hint.
152 if (!isMonospaced(ft)) {
153 _ = c.FT_Done_Face(ft);
154 return error.NoMonospaceFace;
155 }
105 if (c.FT_Set_Pixel_Sizes(ft, 0, px) != 0) { 156 if (c.FT_Set_Pixel_Sizes(ft, 0, px) != 0) {
106 _ = c.FT_Done_Face(ft); 157 _ = c.FT_Done_Face(ft);
107 return error.SizeSet; 158 return error.SizeSet;
@@ -168,6 +219,7 @@ pub const GlyphCache = struct {
168 alloc: std.mem.Allocator, 219 alloc: std.mem.Allocator,
169 face: *Face, 220 face: *Face,
170 glyph_atlas: *atlas.Atlas, 221 glyph_atlas: *atlas.Atlas,
222 family: []const u8 = "monospace",
171 runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty, 223 runs: std.StringHashMapUnmanaged([]quads.PositionedGlyph) = .empty,
172 224
173 pub fn deinit(self: *GlyphCache) void { 225 pub fn deinit(self: *GlyphCache) void {
@@ -183,7 +235,7 @@ pub const GlyphCache = struct {
183 /// cache usable; glyph IDs and bitmap coordinates never cross sizes. 235 /// cache usable; glyph IDs and bitmap coordinates never cross sizes.
184 pub fn setPixelSize(self: *GlyphCache, px: u16) !bool { 236 pub fn setPixelSize(self: *GlyphCache, px: u16) !bool {
185 if (self.face.pixels == px) return false; 237 if (self.face.pixels == px) return false;
186 var next_face = try Face.open(px); 238 var next_face = try Face.openFamily(px, self.family);
187 errdefer next_face.deinit(); 239 errdefer next_face.deinit();
188 var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256); 240 var next_atlas = try atlas.Atlas.init(self.alloc, atlasWidth(px), 256);
189 next_atlas.dirty = true; 241 next_atlas.dirty = true;
@@ -295,6 +347,13 @@ test "display scaling chooses rounded bounded framebuffer font pixels" {
295 } 347 }
296 } 348 }
297 349
350 test "point sizes retain fractions through display scaling" {
351 const macos = @import("builtin").os.tag == .macos;
352 try std.testing.expectEqual(@as(u16, if (macos) 13 else 17), scaledPoints(12.5, 1));
353 try std.testing.expectEqual(@as(u16, if (macos) 19 else 25), scaledPoints(12.5, 1.5));
354 try std.testing.expectEqual(@as(u16, if (macos) 25 else 33), scaledPoints(12.4, 2));
355 }
356
298 test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" { 357 test "scale rebuild replaces glyph bitmaps and cached runs at stable resource addresses" {
299 const alloc = std.testing.allocator; 358 const alloc = std.testing.allocator;
300 var face = try Face.open(16); 359 var face = try Face.open(16);
src/gui/frame.zig
Old New
@@ -28,6 +28,8 @@ pub const Options = struct {
28 session: []const u8 = "0", 28 session: []const u8 = "0",
29 /// Face pixel size at 100% display scale. 29 /// Face pixel size at 100% display scale.
30 font_px: u16 = 16, 30 font_px: u16 = 16,
31 font_family: []const u8 = "monospace",
32 font_points: ?f64 = null,
31 width: u32 = 960, 33 width: u32 = 960,
32 height: u32 = 600, 34 height: u32 = 600,
33 /// Optional integration FIFO; events use the ordinary window input paths. 35 /// Optional integration FIFO; events use the ordinary window input paths.
@@ -305,6 +307,7 @@ const Events = struct {
305 hook: ?*HookReader, 307 hook: ?*HookReader,
306 cache: *font.GlyphCache, 308 cache: *font.GlyphCache,
307 base_font_px: u16, 309 base_font_px: u16,
310 base_font_points: ?f64 = null,
308 geometry_dirty: bool = false, 311 geometry_dirty: bool = false,
309 logical_w: c_int = 0, 312 logical_w: c_int = 0,
310 logical_h: c_int = 0, 313 logical_h: c_int = 0,
@@ -387,7 +390,7 @@ const Events = struct {
387 } 390 }
388 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void { 391 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void {
389 defer self.syncCapture(); 392 defer self.syncCapture();
390 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale)); 393 _ = try self.cache.setPixelSize(if (self.base_font_points) |points| font.scaledPoints(points, scale) else font.scaledPixels(self.base_font_px, scale));
391 const metrics = measuredMetrics(self.cache.face, scale); 394 const metrics = measuredMetrics(self.cache.face, scale);
392 try self.ui.updateGeometry(w, h, metrics); 395 try self.ui.updateGeometry(w, h, metrics);
393 self.geometry_dirty = false; 396 self.geometry_dirty = false;
@@ -445,7 +448,8 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
445 } 448 }
446 449
447 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { 450 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
448 const appearance = &theme_mod.trial; 451 const trial = opts.test_fifo != null and std.mem.eql(u8, std.posix.getenv("MUXG_TEST_THEME") orelse "", "trial");
452 const appearance = if (trial) &theme_mod.trial else &theme_mod.legacy;
449 var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null; 453 var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null;
450 defer if (store) |*s| s.deinit(); 454 defer if (store) |*s| s.deinit();
451 var load_notice: [256]u8 = @splat(0); 455 var load_notice: [256]u8 = @splat(0);
@@ -476,9 +480,9 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
476 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE); 480 _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
477 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"); 481 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");
478 defer c.SDL_DestroyWindow(win); 482 defer c.SDL_DestroyWindow(win);
479 const raster_px = font.scaledPixels(opts.font_px, c.SDL_GetWindowDisplayScale(win)); 483 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));
480 var face = font.Face.open(raster_px) catch |err| { 484 var face = font.Face.openFamily(raster_px, opts.font_family) catch |err| {
481 std.debug.print("muxg: font: {s}\n", .{@errorName(err)}); 485 std.debug.print("muxg: font family '{s}': {s}\n", .{ opts.font_family, @errorName(err) });
482 return 2; 486 return 2;
483 }; 487 };
484 defer face.deinit(); 488 defer face.deinit();
@@ -493,7 +497,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
493 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput"); 497 if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput");
494 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256); 498 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256);
495 defer glyph_atlas.deinit(alloc); 499 defer glyph_atlas.deinit(alloc);
496 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 500 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas, .family = opts.font_family };
497 defer cache.deinit(); 501 defer cache.deinit();
498 var lists: quads.Lists = .{}; 502 var lists: quads.Lists = .{};
499 defer lists.deinit(alloc); 503 defer lists.deinit(alloc);
@@ -520,7 +524,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
520 }; 524 };
521 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 525 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
522 defer if (hook) |*h| h.deinit(); 526 defer if (hook) |*h| h.deinit();
523 var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } }; 527 var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } };
524 defer events.deinit(); 528 defer events.deinit();
525 try events.ui.relayout(); 529 try events.ui.relayout();
526 if (events.ui.layout.len == 0) try events.ui.openPicker(.insert); 530 if (events.ui.layout.len == 0) try events.ui.openPicker(.insert);
src/gui/native.zig
Old New
@@ -16,6 +16,7 @@ pub const frame = @import("frame.zig");
16 pub const bench = @import("bench.zig"); 16 pub const bench = @import("bench.zig");
17 pub const atlas = @import("atlas.zig"); 17 pub const atlas = @import("atlas.zig");
18 pub const font = @import("font.zig"); 18 pub const font = @import("font.zig");
19 pub const config = @import("config.zig");
19 pub const quads = @import("quads.zig"); 20 pub const quads = @import("quads.zig");
20 pub const theme = @import("theme.zig"); 21 pub const theme = @import("theme.zig");
21 pub const gl = @import("gl.zig"); 22 pub const gl = @import("gl.zig");
@@ -40,6 +41,7 @@ test {
40 _ = bench; 41 _ = bench;
41 _ = atlas; 42 _ = atlas;
42 _ = font; 43 _ = font;
44 _ = config;
43 _ = quads; 45 _ = quads;
44 _ = theme; 46 _ = theme;
45 _ = gl; 47 _ = gl;
test/native_fonts.py
Old New
@@ -0,0 +1,261 @@
1 #!/usr/bin/env python3
2 """Config, restart, font pixels and optional Nerd Font/Wayland acceptance.
3
4 Pass --family with an installed fixed-pitch family. --nerd additionally checks
5 private-use icons against missing-glyph output. --output names only an owned
6 headless Sway output; no desktop output may be used.
7 """
8 import argparse
9 import ctypes
10 import ctypes.util
11 import hashlib
12 import json
13 import math
14 import os
15 from pathlib import Path
16 import shlex
17 import subprocess
18 import sys
19
20 sys.dont_write_bytecode = True
21 from native_lifecycle import LifecycleRig, start_persistent
22 from native_resize import by_id
23 from native_theme import pixel
24 from native_tiling import eventually, require
25
26 ICONS = '\uf07b \uf120 \ue0b0'
27 ASCII = 'MWil01_@# abcXYZ'
28
29
30 def config_path(rig):
31 return Path(rig.env['XDG_CONFIG_HOME']) / 'mux/config'
32
33
34 def write_config(rig, text):
35 path = config_path(rig)
36 path.parent.mkdir(exist_ok=True)
37 path.write_text(text)
38 return path
39
40
41 def paint(rig, refs, nerd=False):
42 for pane_id in refs:
43 rig.focus(pane_id)
44 specimen = ('\\033[0m\\033[?25l\\033[2J\\033[H' + ASCII +
45 '\\033[3;1H' + (ICONS if nerd else 'Font selection') +
46 '\\033[5;1H\\033[1mBold\\033[0m \\033[3mItalic\\033[0m' +
47 '\\033[7;1HFONT-READY')
48 rig.shell("export PS1=''; printf '%b' " + shlex.quote(specimen))
49 rig.wait_state(lambda s: 'FONT-READY' in by_id(s)[pane_id]['painted_text'])
50 return rig.state()
51
52
53 def crop(capture, rect):
54 width, height, raw = capture
55 x, y, w, h = (int(rect[k]) for k in ('x', 'y', 'w', 'h'))
56 require(x >= 0 and y >= 0 and x + w <= width and y + h <= height, 'bad crop')
57 return b''.join(raw[((y + r) * width + x) * 3:((y + r) * width + x + w) * 3]
58 for r in range(h))
59
60
61 def signature(rig):
62 state = rig.state()
63 capture = rig.last_pixels()
64 # Compare actual ASCII rasters, excluding cursor and pane header.
65 signatures = []
66 for pane in state['panes']:
67 rect = pane['content'] | {'w': len(ASCII) * state['cell_w'], 'h': state['cell_h']}
68 signatures.append(hashlib.sha256(crop(capture, rect)).hexdigest())
69 require(len(set(signatures)) == 1, 'same text differs across off-origin panes')
70 return state['cell_w'], state['cell_h'], signatures[0]
71
72
73 def font_has_icons(family):
74 result = subprocess.check_output(['fc-match', '-f', '%{file}\n%{index}\n', family], text=True).splitlines()
75 lib = ctypes.CDLL(ctypes.util.find_library('freetype'))
76 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)]
78 lib.FT_Get_Char_Index.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
79 lib.FT_Get_Char_Index.restype = ctypes.c_uint
80 lib.FT_Done_Face.argtypes = [ctypes.c_void_p]
81 lib.FT_Done_FreeType.argtypes = [ctypes.c_void_p]
82 library, face = ctypes.c_void_p(), ctypes.c_void_p()
83 require(lib.FT_Init_FreeType(ctypes.byref(library)) == 0, 'FreeType oracle init')
84 try:
85 require(lib.FT_New_Face(library, os.fsencode(result[0]), int(result[1]), ctypes.byref(face)) == 0,
86 'FreeType oracle face')
87 try:
88 indices = [lib.FT_Get_Char_Index(face, ord(c)) for c in ICONS[::2]]
89 require(all(indices) and len(set(indices)) == 3, 'selected font lacks distinct Nerd Font icons')
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:
93 lib.FT_Done_Face(face)
94 finally:
95 lib.FT_Done_FreeType(library)
96
97
98 def check_icons(rig, refs):
99 for pane_id in refs:
100 rig.focus(pane_id)
101 rig.shell("printf '%b' " + shlex.quote('\\033[3;1H' + ICONS + ' \U0010ffff'))
102 rig.wait_state(lambda s: ICONS in by_id(s)[pane_id]['painted_text'])
103 state = rig.state()
104 capture = rig.last_pixels()
105 for pane in state['panes']:
106 cw, ch = state['cell_w'], state['cell_h']
107 content = pane['content']
108 shapes = [crop(capture, {'x': content['x'] + col * cw, 'y': content['y'] + 2 * ch,
109 'w': cw, 'h': ch}) for col in (0, 2, 4, 6)]
110 require(len(set(shapes)) == 4, 'icons are missing, duplicated or replaced by .notdef')
111 require(all(any(v != 16 for v in shape) for shape in shapes[:3]), 'blank icon')
112
113
114 def invalid_configs(rig):
115 cases = [('font-size = nan\n', '1'), ('# first\nfont-size = 0\n', '2'),
116 ('font-size = 193\n', '1'), ('font-famly = monospace\n', '1'),
117 ('font-family = "unterminated\n', '1'), ('font-size 12\n', '1')]
118 saved = rig.saved.read_bytes()
119 for i, (text, line) in enumerate(cases):
120 path = write_config(rig, text)
121 env = rig.env.copy()
122 env.pop('MUXG_TEST_FIFO', None)
123 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
124 require(result.returncode == 2, f'bad config {i} did not fail promptly')
125 require(str(path) + ':' + line in result.stderr, f'missing file:line diagnostic: {result.stderr}')
126 require(rig.saved.read_bytes() == saved, 'bad config rewrote saved workspace')
127 write_config(rig, 'font-family = MuxDefinitelyMissingFamily012345\n')
128 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
129 require(result.returncode == 2 and 'MuxDefinitelyMissingFamily012345' in result.stderr,
130 'missing family silently substituted or lacked actionable diagnostic')
131 match = subprocess.check_output(['fc-match', '-f', '%{family[0]}\n%{spacing}', 'sans-serif'], text=True).split('\n')
132 if match[1] in ('', '0'):
133 write_config(rig, 'font-family = ' + match[0] + '\n')
134 result = subprocess.run([rig.muxg], env=env, capture_output=True, text=True, timeout=5)
135 require(result.returncode == 2 and match[0] in result.stderr,
136 'installed proportional font was accepted as monospace')
137 write_config(rig, '')
138 for flags in (['--font-px', '16', '--font-size', '12'], ['--font-px', '0'], ['--font-size', 'inf']):
139 result = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, text=True, timeout=5)
140 require(result.returncode == 2, f'invalid CLI accepted: {flags}')
141 write_config(rig, 'bad config\n')
142 result = subprocess.run([rig.muxg, '--help'], env=env, capture_output=True, text=True, timeout=5)
143 require(result.returncode == 0, '--help was blocked by invalid config')
144 rig.ok('invalid config/CLI fail promptly with file:line diagnostics; help still works')
145
146
147 def main():
148 parser = argparse.ArgumentParser(description=__doc__)
149 parser.add_argument('mux')
150 parser.add_argument('muxg')
151 parser.add_argument('--family', default='monospace')
152 parser.add_argument('--nerd', action='store_true')
153 parser.add_argument('--output')
154 args = parser.parse_args()
155 rig = LifecycleRig(args.mux, args.muxg)
156 rig.env.pop('MUXG_TEST_THEME', None)
157 report = {'family': args.family}
158 original_scale = None
159 def scale(value):
160 response = subprocess.run(['swaymsg', '-r', 'output', args.output, 'scale', str(value)],
161 capture_output=True, text=True, check=True)
162 require(all(x['success'] for x in json.loads(response.stdout)), 'scale request failed')
163 try:
164 if args.output:
165 require(args.output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland',
166 'use only owned headless Wayland output')
167 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
168 original_scale = next(o['scale'] for o in outputs if o['name'] == args.output)
169 scale(2)
170 refs = start_persistent(rig)
171 paint(rig, refs)
172 baseline = signature(rig)
173 state = rig.state()
174 pane = state['panes'][0]['content']
175 require(pixel(rig.last_pixels(), pane['x'] + pane['w'] - 2, pane['y'] + 2) == '101010',
176 'missing config did not preserve legacy background')
177 # The kernel shell PID and tty path establish that restarts retain shells.
178 shell_files = {}
179 for pane_id in refs:
180 rig.focus(pane_id)
181 path = rig.root / f'shell-{pane_id}'
182 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
183 eventually(lambda: path.exists() and path.stat().st_size, 'shell PID not written')
184 shell_files[pane_id] = (path, path.read_text())
185 config = 'font-family = "' + args.family + '"\nfont-size = 12.4\n'
186 write_config(rig, config)
187 paint(rig, refs)
188 require(signature(rig) == baseline, 'config changed the already running client')
189 rig.quit()
190 invalid_configs(rig)
191 write_config(rig, config)
192 rig.launch_gui([], 'gui-config-font')
193 paint(rig, refs, args.nerd)
194 configured = signature(rig)
195 rig.kernel_sizes()
196 for pane_id, (path, pid) in shell_files.items():
197 rig.focus(pane_id)
198 path.unlink()
199 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
200 eventually(lambda: path.exists() and path.stat().st_size, 'reopened shell PID not written')
201 require(path.read_text() == pid and Path(rig.tty_paths[pane_id]).exists(), 'restart replaced shell')
202 if args.nerd:
203 report['font_oracle'] = font_has_icons(args.family)
204 check_icons(rig, refs)
205 rig.ok('config applies on restart; three panes retain shells and correct kernel PTY sizes')
206 rig.quit()
207 # Explicit family and size must override conflicting valid config.
208 write_config(rig, 'font-family = monospace\nfont-size = 18\n')
209 rig.launch_gui(['--font-family', args.family, '--font-size', '12.4'], 'gui-cli-font')
210 paint(rig, refs, args.nerd)
211 require(signature(rig) == configured, 'CLI did not override both config font settings')
212 rig.quit()
213 # Record the independently calculated raster size; fractional rounding is also unit-tested.
214 display_scale = 2 if args.output else 1
215 base_dpi = 72 if sys.platform == 'darwin' else 96
216 pixels = math.floor(12.4 * base_dpi / 72 * display_scale + .5)
217 # A .5 base pixel value at 200% cannot be expressed by --font-px;
218 # compare 12pt with 16px separately; the scale round trip checks retained rasters.
219 rig.launch_gui(['--font-family', args.family, '--font-size', '12'], 'gui-twelve-points')
220 paint(rig, refs)
221 points12 = signature(rig)
222 rig.quit()
223 rig.launch_gui(['--font-family', args.family, '--font-px', str(base_dpi // 6)], 'gui-pixel-override')
224 paint(rig, refs)
225 require(signature(rig) == points12, 'point units or legacy pixel override differ')
226 rig.quit()
227 # An empty XDG variable resolves through an isolated HOME, not the real one.
228 home = rig.root / 'font-home'
229 fallback = home / '.config/mux/config'
230 fallback.parent.mkdir(parents=True)
231 fallback.write_text(config)
232 rig.env['HOME'] = str(home)
233 rig.env['XDG_CONFIG_HOME'] = ''
234 rig.launch_gui([], 'gui-home-fallback')
235 paint(rig, refs, args.nerd)
236 require(signature(rig) == configured, 'HOME fallback did not load same config')
237 rig.ok('CLI precedence, point units, --font-px compatibility and HOME fallback verified in pixels')
238 if args.output:
239 for value in (1, 1.5, 2):
240 scale(value)
241 rig.wait_state(lambda s: s['width'] == value * s['logical_width'])
242 paint(rig, refs, args.nerd)
243 rig.kernel_sizes()
244 if args.nerd:
245 check_icons(rig, refs)
246 require(signature(rig) == configured, 'family/size changed after DPI round trip')
247 rig.ok('200% -> 100% -> 150% -> 200% retains family, icons and independent PTY sizes')
248 rig.assert_cli_untouched()
249 report.update(baseline=baseline, configured=configured, expected_raster_pixels=pixels,
250 checks=rig.checkpoints)
251 (rig.root / 'fonts-result.json').write_text(json.dumps(report, indent=2))
252 rig.quit()
253 print('PASS: native fonts;', rig.root, flush=True)
254 finally:
255 rig.close()
256 if original_scale is not None:
257 scale(original_scale)
258
259
260 if __name__ == '__main__':
261 main()
test/native_theme.py
Old New
@@ -1,5 +1,5 @@
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 """Assert the hardcoded appearance through real terminal output and painted pixels.""" 2 """Assert the explicitly selected trial appearance through real terminal output and painted pixels."""
3 import shlex 3 import shlex
4 import sys 4 import sys
5 5
@@ -103,6 +103,7 @@ def modal_samples(rig, kind):
103 def main(): 103 def main():
104 require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG') 104 require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG')
105 rig = ResizeRig(*sys.argv[1:]) 105 rig = ResizeRig(*sys.argv[1:])
106 rig.env['MUXG_TEST_THEME'] = 'trial'
106 try: 107 try:
107 identities, sessions = build_nested(rig) 108 identities, sessions = build_nested(rig)
108 for pane_id, (sock, session) in sessions.items(): 109 for pane_id, (sock, session) in sessions.items():