e9c4a928
feat: configure native fonts and preserve them across display scales
a73x 2026-09-06 05:19
Commit message
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 = "CommitMono Nerd Font Mono"</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(): |