a73x

d0151836

feat: load Ghostty theme files for native appearance

a73x   2026-09-06 06:07

Commit message
feat: load Ghostty theme files for native appearance

README.md
Old New
@@ -67,9 +67,9 @@ 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 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 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 69 full-line `#` comments are accepted; a family may be double quoted. Duplicate
70 keys, unknown keys, malformed values and unavailable families produce diagnostics. 70 keys, unknown mux config keys, malformed values and unavailable families produce diagnostics.
71 Repeated fallback families, per-style families, escapes and inline comments are 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. 72 not supported. This is a supported subset of Ghostty appearance settings.
73 73
74 `--font-family NAME` and `--font-size POINTS` override the config. The existing 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 75 `--font-px N` (1–256 pixels at 100% scale) also overrides config sizing; choose
@@ -78,8 +78,44 @@ and colors remain. Close and reopen the client to apply config edits; daemon
78 sessions survive. Glyphs refresh automatically when display scale changes, 78 sessions survive. Glyphs refresh automatically when display scale changes,
79 retaining the selected family. Installed Nerd Font Mono icons use that face; 79 retaining the selected family. Installed Nerd Font Mono icons use that face;
80 font fallback and cross-cell programming ligatures are not implemented. 80 font fallback and cross-cell programming ligatures are not implemented.
81 Ghostty theme file loading is the next appearance slice. 81
82 Linux 82 Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or
83 `~/.config/mux/themes/`), or by absolute path:
84
85 ```ini
86 font-family = "CommitMono Nerd Font Mono"
87 font-size = 12.4
88 theme = Catppuccin Mocha
89 # Optional override, independent of its position before/after theme:
90 background = #202030
91 ```
92
93 Copy a theme file into that directory yourself; mux does not bundle a catalogue.
94 A theme name is a single filename; relative paths containing directories are refused.
95 `--theme NAME_OR_ABSOLUTE_PATH` overrides the configured theme choice for one launch.
96 Color precedence is original defaults → selected theme → explicit mux config →
97 CLI. Repeated CLI flags use the last value (per index for `--palette`). Use `--background RRGGBB`, `--foreground RRGGBB`, `--cursor-color RRGGBB`,
98 and repeatable `--palette N=RRGGBB` for temporary color overrides.
99
100 Supported theme keys are `background`, `foreground`, `cursor-color`, and
101 `palette = N=RRGGBB`. Colors require six hex digits with an optional `#`;
102 indices are decimal 0–255. Distinct palette indices may repeat the key, but
103 repeating the same index or a singleton key in a file is an error. Missing theme
104 files and malformed supported settings prevent startup with file/line diagnostics.
105 Unsupported keys in a theme file warn and are ignored; unsupported keys in mux's
106 own config remain errors. In particular, imported `selection-background`,
107 `selection-foreground`, and `cursor-text` are not implemented. Theme files cannot
108 set fonts or include other files. Named X11 colors, dynamic color expressions,
109 automatic light/dark selection, and palette-generation options are not supported.
110 See [Ghostty's option reference](https://ghostty.org/docs/config/reference) for the
111 full format that this subset follows.
112
113 Headers, dividers, menus, and bell feedback derive from the final colors.
114 Application-supplied truecolor remains exact. Unspecified palette entries retain
115 the original ANSI/xterm defaults. Restart to apply theme edits; fonts and sessions
116 behave as above.
117
118 Linux
83 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER` 119 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER`
84 or `SDL_VIDEODRIVER` setting overrides that preference. 120 or `SDL_VIDEODRIVER` setting overrides that preference.
85 `kill -USR1 PID` prints the frame timing table to stderr, as does exit. 121 `kill -USR1 PID` prints the frame timing table to stderr, as does exit.
RETRO.md
Old New
@@ -857,7 +857,8 @@ Native source is unchanged, so existing functional validation applies.
857 Delivered font-family and fractional font-size config with explicit CLI precedence, 857 Delivered font-family and fractional font-size config with explicit CLI precedence,
858 restart application, legacy defaults, and installed Nerd Font Mono rendering. 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, 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. 860 color-file loading or ligatures were added. The user approved the recorded demo
861 on 2026-09-06 (“lgtm, continue to next sprint”).
861 862
862 Opening cleanup found the font/cache boundary ready. Closing cleanup removed 863 Opening cleanup found the font/cache boundary ready. Closing cleanup removed
863 early rounding and redundant parser state, rejected malformed/duplicate config, 864 early rounding and redundant parser state, rejected malformed/duplicate config,
@@ -888,10 +889,73 @@ cumulative agent counters (cached input included, not incremental cost) live in
888 - [x] Restore legacy defaults and explicitly select the trial palette in fixtures. 889 - [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] Verify installed Nerd Font Mono, real restart survival and selected-family DPI.
890 - [x] Keep the webpage/video as the primary private handoff. 891 - [x] Keep the webpage/video as the primary private handoff.
891 - [ ] Record slice 2 demo acceptance separately from tests; then begin theme files. 892 - [x] Record slice 2 demo acceptance separately from tests; theme files authorised
893 on 2026-09-06.
892 - [ ] Theme slice: extend the strict parser intentionally and document the supported 894 - [ ] Theme slice: extend the strict parser intentionally and document the supported
893 Ghostty subset, defaults → theme → config → CLI precedence, and missing/default 895 Ghostty subset, defaults → theme → config → CLI precedence, and missing/default
894 behavior. Keep font-family lifetime and late raster rounding intact. 896 behavior. Keep font-family lifetime and late raster rounding intact.
895 - [ ] Renderer follow-up retains the earlier intermittent NVIDIA budget miss and 897 - [ ] Renderer follow-up retains the earlier intermittent NVIDIA budget miss and
896 offscreen surface-growth readback issue; a passing run does not settle either. 898 offscreen surface-growth readback issue; a passing run does not settle either.
897 - [ ] Discuss ligatures only after the preceding appearance slices. 899 - [ ] Discuss ligatures only after the preceding appearance slices.
900
901 ## Native appearance: Ghostty theme loading — 2026-09-06
902
903 Slice 2's recorded demo was approved and theme-file loading authorised. The user
904 rebased the worktree onto the shared GUI build/install/release targets; the demo
905 pair now uses `make install` with both staging and install destinations isolated
906 under the worktree. Opening inspection found no preliminary refactor needed.
907
908 Implemented theme lookup by config-directory filename or absolute path, strict
909 color/palette parsing, theme/config/CLI precedence, diagnostic warnings for
910 unsupported theme keys, and derived chrome for light/dark palettes. No-config
911 and font-only settings retain legacy appearance. Ligatures remain a later scope
912 discussion. Closing cleanup removed a no-op callback, unused alias, and duplicate
913 palette index storage. Review found packed-channel overflow, missing cursor
914 fallback, loose lexical parsing, wrong diagnostic wording, and a contrast
915 calculation that returned different colors from those it measured. Root fixed
916 the final contrast mismatch and added regression checks; real pixels verified
917 the integrated behavior after agent review.
918
919 Native units/core, full native integration, offscreen theme acceptance, NVIDIA
920 theme/DPI acceptance, and retained scale/resize acceptance passed. Final delivery validation and recording are complete; the recorded demo awaits
921 user acceptance. The first CI attempt exposed a
922 baseline QUIC stop timeout; the focused retry passed, but source inspection found
923 that daemon shutdown quietly freed QUIC connections without sending a goodbye.
924 Separate prerequisite commit `693d038` sends CONNECTION_CLOSE before quiet
925 client teardown. Focused checks and full CI/native integration passed afterward. Retain the first failed
926 CI log and avoid calling the intermittent result a deterministic reproduction.
927
928
929 Both final NVIDIA stress runs passed, with default/configured-theme frame p99
930 17,842 / 17,814 us and sampled input upper bounds 55.4 / 70.3 ms. The unchanged
931 limits are 20 ms and 250 ms; input polling adds up to 5 ms. The default run uses
932 `make native-stress`'s paired build artifacts; the configured Latte/Nerd Font run
933 uses the install target's paired ReleaseSafe artifacts. These are acceptance
934 results, not a default-vs-configured performance comparison or speedup claim.
935 Historical NVIDIA misses remain retained. No physical display or new macOS
936 validation is claimed.
937
938 The review page embeds a 30.4-second actual NVIDIA Sway recording at 200%, with
939 restart cuts at 12.4s and 24.0s. Unmodified upstream Catppuccin Mocha/Latte files,
940 source URLs and hashes are retained. The first recording inherited dark text on
941 its explicit RGB background sample under Latte; the final specimen requests
942 white RGB text too. That correction changes demo output only. Root inspected
943 actual recorded frames and the preview. Artifacts, logs, scripts and verified
944 agent-model/counter metadata are in `dist/appearance-slice3/`.
945
946 - [x] Load named and absolute Ghostty theme files with documented subset and
947 defaults → theme → config → CLI precedence, preserving font behavior.
948 - [x] Validate light/dark terminal and chrome pixels, session survival, PTYs,
949 startup failures and warnings, and final CI/native/NVIDIA gates.
950 - [ ] Record the user's slice 3 demo acceptance separately from checks.
951 - [ ] Discuss ligature scope and acceptance with the user before implementation.
952 - [ ] Retain the earlier NVIDIA frame-budget and offscreen growth observations
953 for a renderer follow-up; this sprint's passes do not settle those causes.
954
955
956 Private review page: https://charizard.folk-amberjack.ts.net/appearance-slice3/
957 HTTPS, byte ranges, browser seeking/playback, desktop/mobile layout and earlier
958 routes passed verification. Owned browser/compositor fixtures were stopped; only
959 the allowlisted page server remains. The appearance plan records route-specific
960 teardown. The worktree contains the prerequisite fix and the appearance slice
961 as separate commits; the original checkout's staged user file remains untouched.
build.zig
Old New
@@ -1204,8 +1204,12 @@ pub fn build(b: *std.Build) void {
1204 native_fonts.addArtifactArg(mux_exe); 1204 native_fonts.addArtifactArg(mux_exe);
1205 native_fonts.addArtifactArg(muxg_exe); 1205 native_fonts.addArtifactArg(muxg_exe);
1206 native_fonts.step.dependOn(&native_theme.step); 1206 native_fonts.step.dependOn(&native_theme.step);
1207 const native_theme_config = b.addSystemCommand(&.{ "python3", "-B", "test/native_theme_config.py" });
1208 native_theme_config.addArtifactArg(mux_exe);
1209 native_theme_config.addArtifactArg(muxg_exe);
1210 native_theme_config.step.dependOn(&native_fonts.step);
1207 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1211 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1208 native_e2e_step.dependOn(&native_fonts.step); 1212 native_e2e_step.dependOn(&native_theme_config.step);
1209 1213
1210 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale 1214 // Both paths come from this build graph: a ReleaseSafe GUI beside a stale
1211 // Debug daemon gives misleading latency numbers under raw terminal output. 1215 // Debug daemon gives misleading latency numbers under raw terminal output.
docs/demos/native-appearance-slice2.html
Old New
@@ -23,7 +23,7 @@
23 <div class="eyebrow">muxg · native appearance</div> 23 <div class="eyebrow">muxg · native appearance</div>
24 <h1>Fonts and config for muxg</h1> 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> 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> 26 <div class="status"><i aria-hidden="true"></i>Recorded demo approved · 6 September 2026</div>
27 27
28 <figure class="video-card"> 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"> 29 <video controls playsinline preload="metadata" poster="preview.png" width="1100" height="700" aria-label="Native appearance slice 2 demo">
@@ -53,7 +53,7 @@ font-size = 12.4</code></pre>
53 53
54 <section> 54 <section>
55 <h2>What to review</h2> 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> 56 <div class="review"><p>The user approved this recorded demo on 6 September 2026 (“lgtm, continue to next sprint”). 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> 57 </section>
58 58
59 <section> 59 <section>
docs/demos/native-appearance-slice3.html
Old New
@@ -0,0 +1,61 @@
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 3</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 · slice 3</div>
24 <h1>Your Ghostty themes, in muxg.</h1>
25 <p class="lede">Choose a theme file, add your own color overrides, and reopen the client. The saved shells keep running.</p>
26 <div class="status"><i aria-hidden="true"></i>Validated · demo awaiting your review</div>
27 <figure class="video-card">
28 <video controls playsinline preload="metadata" poster="preview.png" width="1100" height="700" aria-label="Ghostty themes in the actual muxg application">
29 <source src="demo.mp4" type="video/mp4">Your browser cannot play this video. <a href="demo.mp4">Download the recording</a>.
30 </video>
31 <figcaption><span id="metadata">30.4 seconds · 1100 × 700 · NVIDIA Sway at 200% · restart cuts at 12.4s and 24.0s</span><span>No audio · <a href="demo.mp4">direct video</a></span></figcaption>
32 </figure>
33 <section><h2>What this slice achieves</h2><div class="grid">
34 <article><h3>Reuse a theme file</h3><p>Load by name from mux’s themes directory, or use an absolute path. The demo uses unmodified Catppuccin Mocha and Latte Ghostty files.</p></article>
35 <article><h3>Colors throughout the client</h3><p>Terminal defaults, indexed colors and cursor come from the theme. Headers, dividers, menus and bell feedback derive from the final colors. Application truecolor stays exact.</p></article>
36 <article><h3>Overrides you can predict</h3><p>Defaults → theme → explicit config → CLI. Config entries override the chosen theme regardless of where they appear in the file. A CLI override lasts for that launch.</p></article>
37 <article><h3>Apply changes on restart</h3><p>Existing clients keep their colors. Reopening applies the new settings to saved panes while the original daemon sessions keep running. Font settings continue to work.</p></article>
38 </div></section>
39 <section><h2>Try a theme</h2>
40 <pre><code># ~/.config/mux/config
41 font-family = "CommitMono Nerd Font Mono"
42 font-size = 12
43 theme = Catppuccin Mocha
44
45 # Optional personal override:
46 background = #202030</code></pre>
47 <p>Place the theme file at <code>~/.config/mux/themes/Catppuccin Mocha</code>, or use an absolute path after <code>theme =</code>. If you set <code>XDG_CONFIG_HOME</code>, use its <code>mux/themes/</code> directory instead. The themes are copied by you; mux does not bundle a catalogue.</p>
48 <pre><code>muxg --theme "Catppuccin Latte" --background dcebe8</code></pre>
49 <p>The demo uses the <a href="https://github.com/mbadolato/iTerm2-Color-Schemes/blob/master/ghostty/Catppuccin%20Mocha">Mocha</a> and <a href="https://github.com/mbadolato/iTerm2-Color-Schemes/blob/master/ghostty/Catppuccin%20Latte">Latte</a> files from iTerm2-Color-Schemes.</p>
50 </section>
51 <section><h2>The supported subset</h2>
52 <p><code>background</code>, <code>foreground</code>, <code>cursor-color</code>, and <code>palette = N=RRGGBB</code>. Six-digit hex colors may begin with <code>#</code>; palette indices are decimal 0–255. Unspecified entries keep their original defaults.</p>
53 <p>Unsupported theme keys produce a file/line warning and are ignored. The imported files include cursor-text and text-selection colors, which mux does not implement. Unknown keys in mux’s own config, malformed supported colors, duplicate entries, and missing theme files prevent startup with a diagnostic. Theme files do not change fonts or load other files.</p>
54 <p>No hot reload, automatic light/dark switching, named X11 colors, or dynamic color expressions. <a href="https://ghostty.org/docs/config/reference">Ghostty’s full format</a> supports more than this subset.</p>
55 </section>
56 <section><h2>What to review</h2><div class="review"><p>Check readability of the light and dark headers and menus, the color override, and the startup diagnostic. Prefix <strong>Ctrl+\</strong> then <strong>Enter</strong> opens the host picker; <strong>Esc</strong> closes it. After this slice’s review, we’ll discuss whether and how to tackle ligatures.</p></div></section>
57 <section><h2>Validation and limits</h2><p id="validation">Full CI, native units/core, and the full native integration suite passed. Real theme tests verified saved shell identities, kernel PTY sizes, off-origin pixels, light/dark menus, overrides and diagnostics. NVIDIA Wayland scale transitions passed at 200% → 100% → 150% → 200%. Both NVIDIA stress runs passed: final frame p99 17.84 ms with defaults and 17.81 ms with the configured light theme (20 ms limit); sampled input-to-painted upper bounds were 55.4 ms and 70.3 ms (250 ms limit, 5 ms polling). Frame timing includes framebuffer readback and vsync. These are current-build headless results, not a speedup or physical-display claim.</p>
58 <p>The first CI attempt exposed a QUIC shutdown timeout. A separate prerequisite fix now notifies peers before freeing connections; the focused test and full CI passed afterward. The original failure is retained in the sprint record.</p>
59 <p>Earlier intermittent NVIDIA frame-budget misses and an offscreen surface-growth readback issue remain recorded. No physical-display or new macOS validation is claimed.</p></section>
60 <footer>Native appearance slice 3 · private review artifact</footer>
61 </main></body></html>
docs/native-sprint-workflow.md
Old New
@@ -156,10 +156,12 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
156 Build both demo binaries together in an isolated release prefix: 156 Build both demo binaries together in an isolated release prefix:
157 157
158 ```sh 158 ```sh
159 deps/zig/zig build install native -Doptimize=ReleaseSafe -p dist/native-release 159 make install INSTDIR=dist/native-release BINDIR=dist/demo-bin
160 ``` 160 ```
161 161
162 Pass `dist/native-release/bin/mux` and `dist/native-release/bin/muxg` to Rig. 162 The rebased install target builds both binaries in ReleaseSafe; the isolated
163 `BINDIR` keeps demo setup out of the user’s installed executables. Pass
164 `dist/native-release/bin/mux` and `dist/native-release/bin/muxg` to Rig.
163 `make ci` can leave a Debug daemon in `zig-out`; `make native` builds only the 165 `make ci` can leave a Debug daemon in `zig-out`; `make native` builds only the
164 GUI. Checking just `muxg --version` therefore cannot establish the daemon's 166 GUI. Checking just `muxg --version` therefore cannot establish the daemon's
165 build mode. Use the build graph's artifact paths for automated stress tests. 167 build mode. Use the build graph's artifact paths for automated stress tests.
docs/superpowers/plans/2026-09-05-native-appearance.md
Old New
@@ -184,7 +184,7 @@ file loading follows it. When adding config, restore `theme.legacy` as the no-co
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 186
187 ## Active sprint: config and fonts 187 ## Slice 2: config and fonts (recorded demo approved)
188 188
189 Goal: choose an installed font family and point size in mux's config, restart 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. 190 the native client, and continue the same sessions with the new typography.
@@ -218,7 +218,7 @@ frame-budget result; do not count earlier appearance acceptance as a waiver.
218 ### Slice 2 implementation and validation record 218 ### Slice 2 implementation and validation record
219 219
220 Config/fonts implementation, independent review and final functional validation 220 Config/fonts implementation, independent review and final functional validation
221 are complete. The recorded demo awaits user acceptance. 221 are complete. The user approved the recorded demo on 2026-09-06: “lgtm, continue to next sprint”. This does not imply a separate hands-on trial.
222 The parser supports one font-family and one font-size entry, optional family 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 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 224 files use defaults; parse errors include the path and line, and font matching
@@ -288,7 +288,7 @@ in `dist/appearance-slice2/page-server.pid`. Teardown removes only this route wi
288 `tailscale serve --https=443 --set-path /appearance-slice2 off`, then stops that 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. 289 owned server. Browser and HTTPS evidence are retained alongside the recording.
290 290
291 Next: record the user's demo review, then deliver Ghostty theme loading. Do not 291 Next: deliver the authorised Ghostty theme-loading slice. Do not
292 start ligature implementation without the agreed later discussion. 292 start ligature implementation without the agreed later discussion.
293 293
294 Review-page verification passed: private HTTPS canonical URL and 206 byte ranges 294 Review-page verification passed: private HTTPS canonical URL and 206 byte ranges
@@ -300,3 +300,142 @@ frame. The initial browser probe omitted a user gesture and autoplay policy
300 correctly refused playback; the page uses manual controls. `check-final.log` 300 correctly refused playback; the page uses manual controls. `check-final.log`
301 records the precommit gate. Owned compositor/browser fixtures were stopped; the 301 records the precommit gate. Owned compositor/browser fixtures were stopped; the
302 allowlisted review-page server remains running. 302 allowlisted review-page server remains running.
303
304
305 ## Active sprint: Ghostty theme files — 2026-09-06
306
307 Goal: copy a Ghostty theme into mux's themes directory (or name an absolute
308 path), restart muxg, and use the chosen colors across terminal panes and chrome.
309 The user approved slice 2's recorded demo and authorised this next sprint.
310 The user rebased the worktree onto the shared GUI install/release targets;
311 opening inspection confirmed the clean rebased branch and no preliminary
312 refactor is needed. Original user changes in the main checkout remain separate.
313
314 Acceptance:
315
316 - `theme = NAME` resolves under the config directory's `themes/`; absolute paths
317 work. `--theme` chooses the theme for one launch. No bundled theme catalogue,
318 automatic light/dark switching, includes, or hot reload is implied.
319 - Support `background`, `foreground`, `cursor-color`, and repeatable `palette`
320 entries (decimal indices 0–255, six-digit hex with optional `#`). Defaults →
321 selected theme → explicit config → CLI, independent of config line order.
322 - Derive headers, dividers, menus and bell colors from the final palette with
323 readable light/dark chrome. No-config/font-only settings retain exact legacy
324 colors. Application RGB remains exact and unspecified indices retain defaults.
325 - Missing themes and malformed supported values refuse startup with a useful
326 file/line diagnostic. Unsupported theme keys warn and are ignored; unsupported
327 mux config keys remain fatal. No text selection or cursor-text support is
328 implied by importing those keys. Duplicate singleton keys/indices are refused.
329 - Use real saved panes on two daemons, verify shell identities and kernel PTY
330 dimensions across dark/light/config/CLI restarts, plus framebuffer colors,
331 off-origin panes, menus, and a NVIDIA Wayland scale round trip.
332 - Run required CI/native gates and NVIDIA stress separately from recording.
333 Deliver a review webpage and actual GUI video with disclosed restart cuts.
334 Carry forward historical NVIDIA timing misses and offscreen resize observations.
335
336 Luna owns product code and focused units; Terra independently reviews; root owns
337 integration, documentation, real validation and the private demo handoff. Ligature
338 scope remains a discussion after this slice's acceptance.
339
340 ### Slice 3 implementation and review
341
342 The loader and native appearance integration are implemented. A copied theme is
343 read once at startup; the final merged palette is passed to the existing painter.
344 Colors use strict six-digit hex; decimal palette entries are independently
345 optional. Singleton and same-index duplicates in files are fatal. Repeated CLI
346 indices use the last value, matching the other CLI flags.
347
348 Chrome is derived only when a supported color is set. Focus uses palette 6;
349 bell feedback uses palette 3. Unfocused headers mix background toward foreground
350 by 20%, menus by 12%, and dividers by 45%. Selected menu backgrounds mix focus
351 toward terminal background by 60%. Focus/bell/selected text uses whichever of
352 black and white gives higher sRGB contrast. Other text uses terminal foreground.
353 A missing cursor override uses final foreground. No-config/font-only launches
354 keep the exact legacy tokens. The prior hardcoded trial remains an explicit
355 integration fixture.
356
357 Independent review caught a packed-channel narrowing overflow, a missing cursor
358 fallback, loose numeric parsing, inaccurate diagnostics, and a contrast helper
359 that computed white/black ratios but returned different colors. Root closing
360 cleanup fixed the last mismatch, removed redundant palette-pair storage, an
361 unused compatibility alias and no-op warning callback, and added normal-color
362 merge/contrast regression units. The failed focused unit expectation and its
363 passing rerun are retained in `dist/appearance-slice3/review-build.log`.
364
365 Both selected agent models were verified from recorded turn-context metadata;
366 available cumulative counters are retained with cached input identified. Agent
367 agreement was followed by real binary/framebuffer acceptance. The user’s turn
368 interruptions paused the pair; root explicitly resumed them when discovered.
369
370 The rebased `make install INSTDIR=dist/native-release BINDIR=dist/demo-bin` target
371 built both ReleaseSafe binaries without touching the user’s installed programs.
372 Native units/core, full native integration, offscreen theme acceptance, NVIDIA
373 Wayland theme acceptance and the retained scale/drag/resize scenario passed.
374 The new oracle checks three panes on two daemons, shell identities, kernel PTYs,
375 all ANSI colors and extended overrides, inverse defaults, explicit RGB, cursor,
376 light/dark chrome and menus, bell expiry, precedence, warnings and diagnostics.
377
378 The first full CI attempt failed in terminal `05_session`: `mux d stop --quic`
379 remained connected after its five-second deadline. This is retained in `ci.log`;
380 the investigation and prerequisite fix are recorded below.
381
382 The focused pre-fix retry passed, so the CI symptom was intermittent. Inspection
383 found shutdown closed QUIC client sinks quietly before listener cleanup; peers
384 could wait for idle timeout instead of receiving CONNECTION_CLOSE. Separate
385 prerequisite commit `693d038` now calls `closeAll()` before those sinks are freed.
386 The reviewer checked owned/borrowed listener ordering and callback lifetime.
387 `make check` and the real `05_session` group passed after the fix; both release
388 binaries were rebuilt through the install target. Full CI and native integration
389 passed on this final source. No timeout or acceptance budget was relaxed.
390
391 The actual GUI recording is 30.4 seconds, H.264/yuv420p at 1100×700, without
392 audio. It contains three continuous segments with restart cuts at 12.4s and
393 24.0s. Unmodified Catppuccin Mocha and Latte Ghostty files are retained with
394 source URLs and hashes under `dist/appearance-slice3/themes/`. The recording
395 shows both themes, palette output and explicit RGB, typing, host pickers,
396 unchanged running-client colors after config edits, a malformed-color diagnostic,
397 and a CLI background override. Root inspected the decoded dark picker,
398 diagnostic frame and final preview. The first recording's application RGB sample
399 inherited an unreadable text color on its dark swatch under Latte; the specimen
400 now explicitly requests white RGB text as well. This is a demo-output correction,
401 not a renderer change. Both recordings/logs are retained.
402
403
404 ### Final validation and handoff
405
406 All required final gates passed. Evidence under `dist/appearance-slice3/`:
407
408 - `build-final.log` records the native units/core and initial final GUI build;
409 `build-quic-final.log` records the rebuilt release pair after the prerequisite.
410 - `ci-final.log` passes check, full terminal integration, agent and throughput;
411 `native-e2e-final.log` passes the entire native suite on final daemon sources.
412 - `theme-{offscreen,wayland}.log` and their result JSON record theme acceptance;
413 `scale-wayland.log` records the retained scale/drag/resize/PTY/pixel scenario.
414 - `stress-results.json`: default before/final p99 17,806 / 17,842 us, configured
415 Latte/Nerd Font 17,801 / 17,814 us. Sampled input upper bounds 55.4 / 70.3 ms.
416 Default uses `make native-stress` paired build artifacts; configured uses the
417 staged install pair. Limits remain 20,000 us and 250 ms with 5 ms polling.
418 Timings include readback and vsync; no performance improvement or physical
419 display claim is made. Both runs followed CI, other fixtures and recording.
420
421 Recorded-demo acceptance remains pending. Ligatures are the next discussion,
422 not an authorised implementation sprint. The private review page source is
423 `docs/demos/native-appearance-slice3.html`; its staged copy and allowlisted assets
424 are in `dist/appearance-slice3/site/`. Private route and browser verification are
425 recorded below after publication.
426
427
428 Private handoff: https://charizard.folk-amberjack.ts.net/appearance-slice3/
429 Standing Tailscale authorization was used; earlier routes remain available.
430 `page-private-check.json` verifies canonical HTTPS and 206 video/preview byte
431 ranges. Chromium verified the 30.4-second 1100×700 video, seeking to 12s and
432 playback beyond 12.5s, plus desktop 1280px/mobile 390px layouts without horizontal
433 overflow. Root inspected the mobile page and final diagnostic frame.
434
435 The owned browser and NVIDIA compositor were stopped. The allowlisted loopback
436 page server remains on `127.0.0.1:18773`; PID is recorded in
437 `dist/appearance-slice3/page-server.pid` (cleanup details in `cleanup.json`).
438 Teardown removes only this route with
439 `tailscale serve --https=443 --set-path /appearance-slice3 off`, then stops that
440 owned server. Preserve the other review routes. No user desktop outputs or
441 installed binaries were changed. Final recorded-demo acceptance is pending.
src/cli/muxg.zig
Old New
@@ -18,9 +18,15 @@ const PointSize = struct {
18 return .{ .value = value }; 18 return .{ .value = value };
19 } 19 }
20 }; 20 };
21 const Color = struct {
22 value: u32,
23 pub fn parseCLI(text: []const u8) !Color {
24 return .{ .value = native.config.parseColor(text) catch return error.Invalid };
25 }
26 };
21 27
22 const usage = 28 const usage =
23 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-family FAMILY] [--font-size POINTS] [--font-px N] 29 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--theme NAME|PATH] [--background HEX] [--foreground HEX] [--cursor-color HEX] [--palette N=HEX] [--font-family FAMILY] [--font-size POINTS] [--font-px N]
24 \\ 30 \\
25 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace 31 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace
26 \\ --session the session name (default: the daemon's default session) 32 \\ --session the session name (default: the daemon's default session)
@@ -30,6 +36,9 @@ const usage =
30 \\ --font-px font pixels at 100% display scale (default 16) 36 \\ --font-px font pixels at 100% display scale (default 16)
31 \\ --font-family font family (config: font-family) 37 \\ --font-family font family (config: font-family)
32 \\ --font-size font size in points, 1–192 (config: font-size) 38 \\ --font-size font size in points, 1–192 (config: font-size)
39 \\ --theme theme filename in config themes directory or absolute path
40 \\ --background, --foreground, --cursor-color explicit 6-digit colour
41 \\ --palette indexed colour override, N=RRGGBB; may be repeated
33 \\ --help --version 42 \\ --help --version
34 \\ 43 \\
35 ; 44 ;
@@ -42,6 +51,11 @@ const Arguments = struct {
42 font_px: ?u16 = null, 51 font_px: ?u16 = null,
43 font_family: ?[]const u8 = null, 52 font_family: ?[]const u8 = null,
44 font_size: ?PointSize = null, 53 font_size: ?PointSize = null,
54 theme: ?[]const u8 = null,
55 background: ?Color = null,
56 foreground: ?Color = null,
57 cursor_color: ?Color = null,
58 _palette_values: [256]?u32 = [_]?u32{null} ** 256,
45 _target: ?[]const u8 = null, 59 _target: ?[]const u8 = null,
46 _targets: usize = 0, 60 _targets: usize = 0,
47 61
@@ -50,6 +64,13 @@ const Arguments = struct {
50 self._targets += 1; 64 self._targets += 1;
51 return true; 65 return true;
52 } 66 }
67
68 pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize {
69 if (!std.mem.eql(u8, rest[0], "--palette") or rest.len < 2) return 0;
70 const pair = native.config.parsePalette(rest[1]) catch return 0;
71 self._palette_values[pair.index] = pair.color;
72 return 2;
73 }
53 }; 74 };
54 75
55 comptime { 76 comptime {
@@ -80,15 +101,43 @@ pub fn main() !u8 {
80 }; 101 };
81 if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| { 102 if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| {
82 const reason = switch (err) { 103 const reason = switch (err) {
83 error.UnknownKey => "unknown key; supported keys: font-family, font-size", 104 error.UnknownKey => "unknown key; supported keys: font-family, font-size, theme, foreground, background, cursor-color, palette",
84 error.InvalidSyntax => "expected one key = value per line, without duplicate keys", 105 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", 106 error.InvalidValue => "font-size must be a finite number between 1 and 192 points",
107 error.InvalidColor => "colour must be exactly six hexadecimal digits",
108 error.InvalidPalette => "palette must be N=RRGGBB with N between 0 and 255",
109 error.InvalidThemeName => "theme must name a file or an absolute path",
86 error.MissingFamily => "font-family must name an installed monospace family", 110 error.MissingFamily => "font-family must name an installed monospace family",
87 else => @errorName(err), 111 else => @errorName(err),
88 }; 112 };
89 std.debug.print("muxg: config {s}:{d}: {s}\n", .{ path, config_line, reason }); 113 std.debug.print("muxg: config {s}:{d}: {s}\n", .{ path, config_line, reason });
90 return 2; 114 return 2;
91 }; 115 };
116 var explicit: native.theme.Overrides = .{
117 .foreground = if (o.foreground) |v| v.value else settings.foreground,
118 .background = if (o.background) |v| v.value else settings.background,
119 .cursor = if (o.cursor_color) |v| v.value else settings.cursor_color,
120 };
121 for (settings.palette, 0..) |v, i| explicit.palette[i] = v;
122 for (o._palette_values, 0..) |v, i| {
123 if (v) |color| explicit.palette[i] = color;
124 }
125 var selected: native.theme.Overrides = .{};
126 const selection = o.theme orelse if (settings.theme) |v| v else null;
127 if (selection) |name| {
128 const theme_path = resolveThemePath(argv_alloc, config_path, name) catch |err| {
129 if (o.theme == null and config_path != null) {
130 std.debug.print("muxg: config {s}:{d}: invalid theme '{s}': {s}\n", .{ config_path.?, settings.theme_line, name, @errorName(err) });
131 } else std.debug.print("muxg: invalid theme '{s}': {s}\n", .{ name, @errorName(err) });
132 return 2;
133 };
134 var theme_line: usize = 1;
135 var warning_ctx = WarningContext{ .path = theme_path };
136 selected = native.theme.load(argv_alloc, theme_path, &theme_line, &warning_ctx, themeWarning) catch |err| {
137 std.debug.print("muxg: theme {s}:{d}: {s}\n", .{ theme_path, theme_line, if (err == error.InvalidValue) "invalid colour" else if (err == error.InvalidSyntax) "expected one key = value per line" else @errorName(err) });
138 return 2;
139 };
140 }
92 const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets; 141 const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets;
93 if (named > 1) { 142 if (named > 1) {
94 std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage}); 143 std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage});
@@ -120,12 +169,28 @@ pub fn main() !u8 {
120 .font_px = font_px, 169 .font_px = font_px,
121 .font_family = o.font_family orelse settings.family orelse "monospace", 170 .font_family = o.font_family orelse settings.family orelse "monospace",
122 .font_points = font_points, 171 .font_points = font_points,
172 .appearance = native.theme.merge(native.theme.legacy, selected, explicit),
123 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 173 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
124 }) catch |err| { 174 }) catch |err| {
125 std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)}); 175 std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)});
126 return 2; 176 return 2;
127 }; 177 };
128 } 178 }
179
180 const WarningContext = struct { path: []const u8 };
181 fn themeWarning(ctx: ?*anyopaque, line: usize, key: []const u8) void {
182 const info: *WarningContext = @ptrCast(@alignCast(ctx.?));
183 std.debug.print("{s}:{d}: unsupported theme key {s}; ignored\n", .{ info.path, line, key });
184 }
185
186 fn resolveThemePath(alloc: std.mem.Allocator, config_path: ?[]const u8, name: []const u8) ![]const u8 {
187 if (name.len == 0) return error.InvalidThemeName;
188 if (std.fs.path.isAbsolute(name)) return alloc.dupe(u8, name);
189 if (std.mem.indexOfAny(u8, name, "/\\") != null or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidThemeName;
190 const cfg = config_path orelse return error.NoHome;
191 const dir = std.fs.path.dirname(cfg) orelse return error.InvalidThemeName;
192 return std.fmt.allocPrint(alloc, "{s}/themes/{s}", .{ dir, name });
193 }
129 fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target { 194 fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target {
130 var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false); 195 var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false);
131 if (target == .hand) target.hand.narrate = true; 196 if (target == .hand) target.hand.narrate = true;
src/gui/config.zig
Old New
@@ -1,11 +1,36 @@
1 //! Small, explicit native font configuration parser. 1 //! Small, explicit native appearance and font configuration parser.
2 const std = @import("std"); 2 const std = @import("std");
3 3
4 pub const Settings = struct { 4 pub const Settings = struct {
5 family: ?[:0]const u8 = null, 5 family: ?[:0]const u8 = null,
6 size_points: ?f64 = null, 6 size_points: ?f64 = null,
7 theme: ?[:0]const u8 = null,
8 theme_line: usize = 0,
9 foreground: ?u32 = null,
10 background: ?u32 = null,
11 cursor_color: ?u32 = null,
12 palette: [256]?u32 = [_]?u32{null} ** 256,
7 }; 13 };
8 pub const Error = error{ InvalidSyntax, UnknownKey, InvalidValue, MissingFamily, OutOfMemory }; 14 pub const Error = error{ InvalidSyntax, UnknownKey, InvalidValue, InvalidColor, InvalidPalette, InvalidThemeName, MissingFamily, OutOfMemory };
15
16 pub fn parseColor(text: []const u8) Error!u32 {
17 var s = text;
18 if (s.len > 0 and s[0] == '#') s = s[1..];
19 if (s.len != 6) return error.InvalidValue;
20 for (s) |ch| if (!((ch >= '0' and ch <= '9') or (ch >= 'a' and ch <= 'f') or (ch >= 'A' and ch <= 'F'))) return error.InvalidValue;
21 const rgb = std.fmt.parseInt(u24, s, 16) catch return error.InvalidValue;
22 return (@as(u32, rgb) << 8) | 0xff;
23 }
24
25 pub const PalettePair = struct { index: u8, color: u32 };
26
27 pub fn parsePalette(text: []const u8) Error!PalettePair {
28 const eq = std.mem.indexOfScalar(u8, text, '=') orelse return error.InvalidPalette;
29 if (eq == 0 or eq + 1 >= text.len) return error.InvalidPalette;
30 for (text[0..eq]) |ch| if (ch < '0' or ch > '9') return error.InvalidPalette;
31 const index = std.fmt.parseInt(u8, text[0..eq], 10) catch return error.InvalidPalette;
32 return .{ .index = index, .color = parseColor(std.mem.trim(u8, text[eq + 1 ..], " \t")) catch return error.InvalidPalette };
33 }
9 34
10 // 192pt is the largest accepted size: at Linux's 96 DPI it is 256px at 100%. 35 // 192pt is the largest accepted size: at Linux's 96 DPI it is 256px at 100%.
11 36
@@ -21,7 +46,10 @@ fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 {
21 46
22 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Error!Settings { 47 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Error!Settings {
23 var out: Settings = .{}; 48 var out: Settings = .{};
24 errdefer if (out.family) |f| alloc.free(f); 49 errdefer {
50 if (out.family) |f| alloc.free(f);
51 if (out.theme) |t| alloc.free(t);
52 }
25 var it = std.mem.splitScalar(u8, bytes, '\n'); 53 var it = std.mem.splitScalar(u8, bytes, '\n');
26 var line_no: usize = 0; 54 var line_no: usize = 0;
27 while (it.next()) |raw| { 55 while (it.next()) |raw| {
@@ -56,6 +84,26 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Err
56 return error.InvalidValue; 84 return error.InvalidValue;
57 } 85 }
58 out.size_points = points; 86 out.size_points = points;
87 } else if (std.mem.eql(u8, key, "theme")) {
88 if (out.theme != null) return error.InvalidSyntax;
89 out.theme = valueText(value, alloc) catch |err| return if (err == error.MissingFamily) error.InvalidThemeName else err;
90 out.theme_line = line_no;
91 } else if (std.mem.eql(u8, key, "foreground") or std.mem.eql(u8, key, "background") or std.mem.eql(u8, key, "cursor-color")) {
92 const parsed = parseColor(value) catch return error.InvalidColor;
93 if (std.mem.eql(u8, key, "foreground")) {
94 if (out.foreground != null) return error.InvalidSyntax;
95 out.foreground = parsed;
96 } else if (std.mem.eql(u8, key, "background")) {
97 if (out.background != null) return error.InvalidSyntax;
98 out.background = parsed;
99 } else {
100 if (out.cursor_color != null) return error.InvalidSyntax;
101 out.cursor_color = parsed;
102 }
103 } else if (std.mem.eql(u8, key, "palette")) {
104 const pair = parsePalette(value) catch return error.InvalidPalette;
105 if (out.palette[pair.index] != null) return error.InvalidSyntax;
106 out.palette[pair.index] = pair.color;
59 } else { 107 } else {
60 return error.UnknownKey; 108 return error.UnknownKey;
61 } 109 }
@@ -92,3 +140,12 @@ test "config refuses duplicates and malformed text at the actual line" {
92 try std.testing.expectError(error.InvalidSyntax, parse(a, "font-family = \"mono\"space\"\n", null)); 140 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)); 141 try std.testing.expectError(error.MissingFamily, parse(a, "font-family = \" \"\n", null));
94 } 142 }
143
144 test "colors require exact hexadecimal and decimal palette syntax" {
145 try std.testing.expectEqual(@as(u32, 0x112233ff), try parseColor("#112233"));
146 try std.testing.expectError(error.InvalidValue, parseColor("+11223"));
147 try std.testing.expectError(error.InvalidValue, parseColor("11223_3"));
148 try std.testing.expectError(error.InvalidPalette, parsePalette("+1=#112233"));
149 try std.testing.expectEqual(@as(u8, 255), (try parsePalette("255=abcdef")).index);
150 try std.testing.expectError(error.InvalidPalette, parsePalette("256=abcdef"));
151 }
src/gui/frame.zig
Old New
@@ -30,6 +30,7 @@ pub const Options = struct {
30 font_px: u16 = 16, 30 font_px: u16 = 16,
31 font_family: []const u8 = "monospace", 31 font_family: []const u8 = "monospace",
32 font_points: ?f64 = null, 32 font_points: ?f64 = null,
33 appearance: theme_mod.Theme = theme_mod.legacy,
33 width: u32 = 960, 34 width: u32 = 960,
34 height: u32 = 600, 35 height: u32 = 600,
35 /// Optional integration FIFO; events use the ordinary window input paths. 36 /// Optional integration FIFO; events use the ordinary window input paths.
@@ -449,7 +450,8 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
449 450
450 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { 451 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
451 const trial = opts.test_fifo != null and std.mem.eql(u8, std.posix.getenv("MUXG_TEST_THEME") orelse "", "trial"); 452 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; 453 const configured_appearance = opts.appearance;
454 const appearance = if (trial) &theme_mod.trial else &configured_appearance;
453 var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null; 455 var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null;
454 defer if (store) |*s| s.deinit(); 456 defer if (store) |*s| s.deinit();
455 var load_notice: [256]u8 = @splat(0); 457 var load_notice: [256]u8 = @splat(0);
src/gui/theme.zig
Old New
@@ -4,6 +4,125 @@
4 //! carries terminal colours as tagged values; indexed entries use this palette 4 //! carries terminal colours as tagged values; indexed entries use this palette
5 //! while explicit application RGB colours retain their values. 5 //! while explicit application RGB colours retain their values.
6 const std = @import("std"); 6 const std = @import("std");
7 const config = @import("config.zig");
8
9 pub const Overrides = struct {
10 foreground: ?u32 = null,
11 background: ?u32 = null,
12 cursor: ?u32 = null,
13 palette: [256]?u32 = [_]?u32{null} ** 256,
14
15 pub fn any(self: Overrides) bool {
16 if (self.foreground != null or self.background != null or self.cursor != null) return true;
17 for (self.palette) |v| if (v != null) return true;
18 return false;
19 }
20 };
21
22 pub const WarningFn = *const fn (ctx: ?*anyopaque, line: usize, key: []const u8) void;
23
24 pub fn parseTheme(bytes: []const u8, line_out: ?*usize, ctx: ?*anyopaque, callback: ?WarningFn) !Overrides {
25 var out: Overrides = .{};
26 var it = std.mem.splitScalar(u8, bytes, '\n');
27 var line_no: usize = 0;
28 while (it.next()) |raw| {
29 line_no += 1;
30 if (line_out) |p| p.* = line_no;
31 const line = std.mem.trim(u8, raw, " \t\r");
32 if (line.len == 0 or line[0] == '#') continue;
33 const eq = std.mem.indexOfScalar(u8, line, '=') orelse return error.InvalidSyntax;
34 const key = std.mem.trim(u8, line[0..eq], " \t");
35 const value = std.mem.trim(u8, line[eq + 1 ..], " \t");
36 if (key.len == 0) return error.InvalidSyntax;
37 if (std.mem.eql(u8, key, "background") or std.mem.eql(u8, key, "foreground") or std.mem.eql(u8, key, "cursor-color")) {
38 const color = config.parseColor(value) catch return error.InvalidValue;
39 if (std.mem.eql(u8, key, "background")) {
40 if (out.background != null) return error.InvalidSyntax;
41 out.background = color;
42 } else if (std.mem.eql(u8, key, "foreground")) {
43 if (out.foreground != null) return error.InvalidSyntax;
44 out.foreground = color;
45 } else {
46 if (out.cursor != null) return error.InvalidSyntax;
47 out.cursor = color;
48 }
49 } else if (std.mem.eql(u8, key, "palette")) {
50 const pair = config.parsePalette(value) catch return error.InvalidValue;
51 if (out.palette[pair.index] != null) return error.InvalidSyntax;
52 out.palette[pair.index] = pair.color;
53 } else {
54 if (callback) |warn| warn(ctx, line_no, key);
55 }
56 }
57 return out;
58 }
59
60 pub fn load(alloc: std.mem.Allocator, path: []const u8, line_out: ?*usize, ctx: ?*anyopaque, callback: ?WarningFn) !Overrides {
61 const file = try std.fs.cwd().openFile(path, .{});
62 defer file.close();
63 const bytes = try file.readToEndAlloc(alloc, 64 * 1024);
64 defer alloc.free(bytes);
65 return parseTheme(bytes, line_out, ctx, callback);
66 }
67
68 pub fn merge(base: Theme, theme: Overrides, explicit: Overrides) Theme {
69 var out = base;
70 var colors = theme;
71 if (explicit.foreground) |v| colors.foreground = v;
72 if (explicit.background) |v| colors.background = v;
73 if (explicit.cursor) |v| colors.cursor = v;
74 for (explicit.palette, 0..) |v, i| {
75 if (v) |c| colors.palette[i] = c;
76 }
77 if (!colors.any()) return out;
78 if (colors.foreground) |v| out.terminal_fg = v;
79 if (colors.background) |v| out.terminal_bg = v;
80 out.cursor = colors.cursor orelse out.terminal_fg;
81 for (colors.palette, 0..) |v, i| {
82 if (v) |c| out.palette[i] = c;
83 }
84 const bg = out.terminal_bg;
85 const fg = out.terminal_fg;
86 const focus = out.palette[6];
87 const bell = out.palette[3];
88 out.chrome_focus_bg = focus;
89 out.chrome_focus_fg = contrast(focus);
90 out.chrome_unfocused_bg = blend(bg, fg, 20);
91 out.chrome_unfocused_fg = fg;
92 out.bell_header_bg = bell;
93 out.bell_header_fg = contrast(bell);
94 out.modal_bg = blend(bg, fg, 12);
95 out.modal_fg = fg;
96 out.modal_selected_bg = blend(focus, bg, 60);
97 out.modal_selected_fg = contrast(out.modal_selected_bg);
98 out.divider = blend(bg, fg, 45);
99 return out;
100 }
101
102 fn channels(c: u32) [3]u8 {
103 return .{ @truncate(c >> 24), @truncate(c >> 16), @truncate(c >> 8) };
104 }
105 fn pack(c: [3]u8) u32 {
106 return (@as(u32, c[0]) << 24) | (@as(u32, c[1]) << 16) | (@as(u32, c[2]) << 8) | 0xff;
107 }
108 fn blend(a: u32, b: u32, amount: u8) u32 {
109 const x = channels(a);
110 const y = channels(b);
111 return pack(.{ @intCast((@as(u16, x[0]) * (100 - amount) + @as(u16, y[0]) * amount) / 100), @intCast((@as(u16, x[1]) * (100 - amount) + @as(u16, y[1]) * amount) / 100), @intCast((@as(u16, x[2]) * (100 - amount) + @as(u16, y[2]) * amount) / 100) });
112 }
113 fn contrast(c: u32) u32 {
114 const x = channels(c);
115 const lum = linear(@as(f64, @floatFromInt(x[0])) / 255.0) * 0.2126 +
116 linear(@as(f64, @floatFromInt(x[1])) / 255.0) * 0.7152 +
117 linear(@as(f64, @floatFromInt(x[2])) / 255.0) * 0.0722;
118 const black_ratio = (lum + 0.05) / 0.05;
119 const white_ratio = 1.05 / (lum + 0.05);
120 return if (white_ratio >= black_ratio) 0xffffffff else 0x000000ff;
121 }
122
123 fn linear(v: f64) f64 {
124 return if (v <= 0.04045) v / 12.92 else std.math.pow(f64, (v + 0.055) / 1.055, 2.4);
125 }
7 126
8 pub const Theme = struct { 127 pub const Theme = struct {
9 terminal_fg: u32, 128 terminal_fg: u32,
@@ -49,8 +168,7 @@ const legacy_ansi = [16]u32{
49 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff, 168 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff,
50 }; 169 };
51 170
52 /// The palette and chrome used before appearance was centralised. Keep this 171 /// Original no-config appearance, including its unchanged chrome.
53 /// named value so a later configuration slice can make it selectable.
54 pub const legacy: Theme = .{ 172 pub const legacy: Theme = .{
55 .terminal_fg = 0xd0d0d0ff, 173 .terminal_fg = 0xd0d0d0ff,
56 .terminal_bg = 0x101010ff, 174 .terminal_bg = 0x101010ff,
@@ -76,7 +194,7 @@ const trial_ansi = [16]u32{
76 0xa8cbe8ff, 0xd1a9d1ff, 0x8cddd0ff, 0xfff4dfff, 194 0xa8cbe8ff, 0xd1a9d1ff, 0x8cddd0ff, 0xfff4dfff,
77 }; 195 };
78 196
79 /// Hardcoded cohesive appearance trial for the native launch in this slice. 197 /// Explicit integration fixture for the original appearance acceptance.
80 pub const trial: Theme = .{ 198 pub const trial: Theme = .{
81 .terminal_fg = 0xf3e8d0ff, 199 .terminal_fg = 0xf3e8d0ff,
82 .terminal_bg = 0x211b1aff, 200 .terminal_bg = 0x211b1aff,
@@ -102,3 +220,31 @@ test "legacy and trial retain complete generated indexed palettes" {
102 try std.testing.expectEqual(@as(u32, 0xd46a5aff), trial.palette[1]); 220 try std.testing.expectEqual(@as(u32, 0xd46a5aff), trial.palette[1]);
103 try std.testing.expect(trial.palette[16] != legacy.palette[16] or trial.palette[1] != legacy.palette[1]); 221 try std.testing.expect(trial.palette[16] != legacy.palette[16] or trial.palette[1] != legacy.palette[1]);
104 } 222 }
223
224 test "custom appearance merges sparse colors and derives chrome without losing defaults" {
225 const unchanged = merge(legacy, .{}, .{});
226 try std.testing.expectEqualDeep(legacy, unchanged);
227 var chosen: Overrides = .{ .background = 0xeff1f5ff, .foreground = 0x4c4f69ff };
228 chosen.palette[6] = 0x179299ff;
229 chosen.palette[200] = 0x112233ff;
230 const appearance = merge(legacy, chosen, .{ .foreground = 0x263340ff });
231 try std.testing.expectEqual(@as(u32, 0x263340ff), appearance.cursor);
232 try std.testing.expectEqual(@as(u32, 0xeff1f5ff), appearance.terminal_bg);
233 try std.testing.expectEqual(@as(u32, 0x112233ff), appearance.palette[200]);
234 try std.testing.expectEqual(legacy.palette[17], appearance.palette[17]);
235 try std.testing.expectEqual(@as(u32, 0x000000ff), appearance.chrome_focus_fg);
236 try std.testing.expectEqual(@as(u32, 0xffffffff), contrast(0x444444ff));
237 try std.testing.expectEqual(@as(u32, 0x000000ff), contrast(0x999999ff));
238 const cursor_override = merge(legacy, .{ .cursor = 0xabcdefFF }, .{ .foreground = 0x123456ff });
239 try std.testing.expectEqual(@as(u32, 0xabcdefff), cursor_override.cursor);
240 }
241
242 test "theme parser preserves line diagnostics and ignores unsupported reset keys" {
243 var line: usize = 0;
244 const parsed = try parseTheme("font-family =\nforeground = #112233\n", &line, null, null);
245 try std.testing.expectEqual(@as(?u32, 0x112233ff), parsed.foreground);
246 try std.testing.expectError(error.InvalidValue, parseTheme("# heading\npalette = 256=abcdef\n", &line, null, null));
247 try std.testing.expectEqual(@as(usize, 2), line);
248 try std.testing.expectError(error.InvalidSyntax, parseTheme("background=abcdef\nbackground=112233\n", &line, null, null));
249 try std.testing.expectEqual(@as(usize, 2), line);
250 }
test/native_theme_config.py
Old New
@@ -0,0 +1,246 @@
1 #!/usr/bin/env python3
2 """Theme file/config/CLI acceptance through saved sessions and retained pixels."""
3 import argparse
4 import json
5 import os
6 from pathlib import Path
7 import shlex
8 import subprocess
9 import sys
10
11 sys.dont_write_bytecode = True
12 from native_fonts import write_config
13 from native_lifecycle import LifecycleRig, start_persistent
14 from native_resize import by_id
15 from native_theme import check_samples, edge_sample
16 from native_tiling import eventually, require
17
18 DARK = {'background': '19232d', 'foreground': 'e8dcc8', 'cursor-color': 'efb456',
19 'palette': {i: f'{40+i*10:02x}{60+i*7:02x}{80+i*5:02x}' for i in range(16)}}
20 LIGHT = {'background': 'faf1df', 'foreground': '263340', 'cursor-color': '874329',
21 'palette': {i: f'{30+i*8:02x}{50+i*5:02x}{70+i*6:02x}' for i in range(16)}}
22 DARK['palette'].update({200: 'b8569a', 255: 'cbd5dd'})
23 LIGHT['palette'].update({200: '874780', 255: '475967'})
24
25
26 def theme_text(theme):
27 return '# Ghostty color syntax\n' + ''.join(f'{k} = #{theme[k]}\n' for k in
28 ('background', 'foreground', 'cursor-color')) + ''.join(
29 f'palette = {i}={color}\n' for i, color in theme['palette'].items())
30
31
32 def specimen(rig, refs):
33 for pane_id in refs:
34 rig.focus(pane_id)
35 text = '\\033[0m\\033[?25h\\033[2J\\033[HTHEME COLORS'
36 for i in range(16):
37 text += f'\\033[{3+i//8};{1+4*(i%8)}H\\033[48;5;{i}m \\033[0m'
38 for col, index in ((1, 17), (5, 200), (9, 255)):
39 text += f'\\033[6;{col}H\\033[48;5;{index}m \\033[0m'
40 text += ('\\033[7;1H\\033[7m \\033[0m'
41 '\\033[8;1H\\033[48;2;18;52;86m \\033[0m'
42 '\\033[10;1HTHEME-CONFIG-READY\\033[12;2H')
43 rig.shell("export PS1=''; printf '%b' " + shlex.quote(text))
44 rig.wait_state(lambda s: 'THEME-CONFIG-READY' in by_id(s)[pane_id]['painted_text'])
45
46
47 def colors(rig, theme):
48 state = rig.state()
49 samples = []
50 for pane in state['panes']:
51 require(pane['cols'] >= 32 and pane['rows'] >= 12, 'theme specimen does not fit pane')
52 rect = pane['content']
53 cw, ch = state['cell_w'], state['cell_h']
54 def cell(label, col, row, color):
55 return label, rect['x']+(col+.5)*cw, rect['y']+(row+.5)*ch, color
56 samples.append(edge_sample('terminal background', rect, theme['background']))
57 for i in range(16):
58 samples.append(cell(f'ANSI {i}', 4*(i%8), 2+i//8, theme['palette'][i]))
59 for col, color in ((0, '00005f'), (4, theme['palette'][200]), (8, theme['palette'][255])):
60 samples.append(cell('extended palette', col, 5, color))
61 samples += [cell('inverse foreground', 0, 6, theme['foreground']),
62 cell('explicit RGB', 0, 7, '123456')]
63 if pane['id'] == state['focus']:
64 samples.append(cell('cursor', 1, 11, theme['cursor-color']))
65 check_samples(rig, samples, 'loaded terminal colors including off-origin panes')
66 rig.kernel_sizes()
67
68
69 def chrome(rig, expected):
70 state = rig.state()
71 samples = [edge_sample('derived header', p['header'], expected['focus'] if p['id'] == state['focus']
72 else expected['inactive']) for p in state['panes']]
73 samples += [('divider', d['rect']['x'], d['rect']['y'], expected['divider']) for d in state['dividers']]
74 check_samples(rig, samples, 'derived headers and dividers')
75 for kind in ('picker', 'recovery'):
76 rig.chord('enter' if kind == 'picker' else 'p')
77 if kind == 'picker': rig.picker('hosts')
78 modal = rig.wait_state(lambda s: s.get(kind))[kind]
79 samples = [edge_sample('modal background', modal['rect'], expected['modal'])]
80 for i, row in enumerate(modal['rows']):
81 if row['rect']['h']:
82 samples.append(edge_sample('modal row', row['rect'], expected['selected']
83 if i == modal.get('selected', 0) else expected['modal']))
84 check_samples(rig, samples, 'derived light/dark modal colors')
85 rig.key('escape')
86 if kind == 'picker': rig.key('escape')
87 state = rig.state(); header = by_id(state)[state['focus']]['header']
88 rig.shell("printf '\\007'")
89 check_samples(rig, [edge_sample('bell', header, expected['bell'])], 'derived bell color')
90 check_samples(rig, [edge_sample('focus', header, expected['focus'])], 'bell returns to focus')
91
92
93 def shell_ids(rig, refs):
94 result = {}
95 for pane_id in refs:
96 rig.focus(pane_id)
97 path = rig.root / f'pid-{pane_id}'
98 path.unlink(missing_ok=True)
99 rig.shell('printf %s "$$" > ' + shlex.quote(str(path)))
100 eventually(lambda: path.exists() and path.stat().st_size, 'shell identity not written')
101 result[pane_id] = path.read_text()
102 return result
103
104
105 def rejected(rig, config, path, line, flags=()):
106 write_config(rig, config)
107 saved = rig.saved.read_bytes()
108 env = rig.env.copy()
109 env.pop('MUXG_TEST_FIFO', None)
110 proc = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, text=True, timeout=5)
111 require(proc.returncode == 2, f'invalid theme launch accepted: {proc.stderr}')
112 require(str(path) in proc.stderr and (line is None or f':{line}' in proc.stderr),
113 f'wrong diagnostic source: {proc.stderr}')
114 require(saved == rig.saved.read_bytes(), 'rejected config changed saved workspace')
115
116
117 def main():
118 parser = argparse.ArgumentParser(description=__doc__)
119 parser.add_argument('mux'); parser.add_argument('muxg'); parser.add_argument('--output')
120 args = parser.parse_args()
121 rig = LifecycleRig(args.mux, args.muxg)
122 rig.env.pop('MUXG_TEST_THEME', None)
123 original_scale = None
124 def scale(value):
125 response = subprocess.check_output(['swaymsg', '-r', 'output', args.output, 'scale', str(value)])
126 require(all(x['success'] for x in json.loads(response)), 'scale request failed')
127 try:
128 if args.output:
129 require(args.output.startswith('HEADLESS-') and rig.env['SDL_VIDEO_DRIVER'] == 'wayland',
130 'only an owned headless Wayland output is allowed')
131 outputs = json.loads(subprocess.check_output(['swaymsg', '-r', '-t', 'get_outputs']))
132 original_scale = next(o['scale'] for o in outputs if o['name'] == args.output)
133 scale(2)
134 refs = start_persistent(rig)
135 state = rig.state()
136 check_samples(rig, [edge_sample('default background', p['content'], '101010')
137 for p in state['panes']], 'fresh no-config legacy appearance')
138 rig.drag('stacked', dy=state['cell_h']*4)
139 ids = shell_ids(rig, refs)
140 cfg = write_config(rig, '')
141 themes = cfg.parent / 'themes'; themes.mkdir()
142 dark = themes / 'Dark Test'; dark.write_text(theme_text(DARK))
143 light = rig.root / 'Light Test'; light.write_text(theme_text(LIGHT))
144 write_config(rig, 'theme = "Dark Test"\n')
145 check_samples(rig, [edge_sample('no reload', p['content'], '101010')
146 for p in rig.state()['panes']], 'running client retains legacy background')
147 rig.quit(); rig.launch_gui([], 'gui-dark')
148 require(shell_ids(rig, refs) == ids, 'dark restart replaced shells')
149 specimen(rig, refs); colors(rig, DARK)
150 chrome(rig, {'focus': '64666e', 'inactive': '42484c', 'divider': '767672',
151 'modal': '31393f', 'selected': '373d47', 'bell': '46515f'})
152 rig.ok('named theme loads all ANSI, extended overrides, cursor and inverse; explicit RGB stays exact')
153 rig.quit()
154 # CLI theme selection wins over a missing configured theme; explicit config still wins its colors.
155 write_config(rig, 'background = #e9eadb\npalette = 200=778899\ntheme = Missing\n')
156 flags = ['--theme', str(light), '--foreground', '142536', '--cursor-color', '#984321',
157 '--palette', '1=654321', '--palette', '255=123456']
158 rig.launch_gui(flags, 'gui-light-overrides')
159 merged = LIGHT | {'background': 'e9eadb', 'foreground': '142536', 'cursor-color': '984321',
160 'palette': LIGHT['palette'] | {1: '654321', 200: '778899', 255: '123456'}}
161 require(shell_ids(rig, refs) == ids, 'light restart replaced shells')
162 specimen(rig, refs); colors(rig, merged)
163 rig.ok('absolute CLI theme, config before theme, multiple CLI palette entries and saved shells verified')
164 if args.output:
165 for value in (1, 1.5, 2):
166 scale(value)
167 rig.wait_state(lambda s: s['width'] == value*s['logical_width'])
168 specimen(rig, refs); colors(rig, merged)
169 rig.ok('configured colors and kernel PTYs survive NVIDIA scale round trip')
170 rig.quit()
171 # Config after theme overrides each color category; CLI wins over a valid selected theme.
172 write_config(rig, 'theme = "' + str(light) + '"\nbackground = a1b2c3\nforeground = 213243\ncursor-color = 987654\npalette = 200=563412\n')
173 rig.launch_gui([], 'gui-absolute-config')
174 configured = LIGHT | {'background': 'a1b2c3', 'foreground': '213243', 'cursor-color': '987654',
175 'palette': LIGHT['palette'] | {200: '563412'}}
176 specimen(rig, refs); colors(rig, configured); rig.quit()
177 write_config(rig, 'theme = Dark Test\n')
178 rig.launch_gui(['--theme', str(light), '--background', 'abcdef',
179 '--palette', '1=111111', '--palette', '1=654321'], 'gui-valid-theme-override')
180 overridden = LIGHT | {'background': 'abcdef', 'palette': LIGHT['palette'] | {1: '654321'}}
181 specimen(rig, refs); colors(rig, overridden); rig.quit()
182 # HOME fallback finds named themes beside fallback config.
183 home = rig.root / 'isolated-home'
184 fallback = home / '.config/mux'; (fallback / 'themes').mkdir(parents=True)
185 (fallback / 'config').write_text('theme = Fallback\n')
186 (fallback / 'themes/Fallback').write_text(theme_text(LIGHT))
187 old_home, old_xdg = rig.env.get('HOME'), rig.env['XDG_CONFIG_HOME']
188 rig.env.update(HOME=str(home), XDG_CONFIG_HOME='')
189 rig.launch_gui([], 'gui-fallback'); specimen(rig, refs); colors(rig, LIGHT)
190 chrome(rig, {'focus': '4e506a', 'inactive': 'cfcbbf', 'divider': '9a9b97',
191 'modal': 'e0dacb', 'selected': 'b5b0b0', 'bell': '364158'})
192 rig.quit()
193 rig.env['XDG_CONFIG_HOME'] = old_xdg
194 if old_home is not None: rig.env['HOME'] = old_home
195 else: rig.env.pop('HOME', None)
196 # Foreground-only theme inherits legacy background/palette and defaults cursor to final foreground.
197 partial = themes / 'Partial'; partial.write_text('foreground = #abcdef\n')
198 write_config(rig, 'theme = Partial\n')
199 rig.launch_gui([], 'gui-partial'); specimen(rig, refs)
200 state = rig.state(); focused = by_id(state)[state['focus']]; rect = focused['content']
201 check_samples(rig, [edge_sample('inherited background', rect, '101010'),
202 ('default cursor', rect['x']+1.5*state['cell_w'], rect['y']+11.5*state['cell_h'], 'abcdef'),
203 ('inherited red', rect['x']+4.5*state['cell_w'], rect['y']+2.5*state['cell_h'], 'cc0000')],
204 'partial theme inherits defaults and foreground cursor')
205 rig.quit()
206 # Unsupported theme options must warn and have no side effects.
207 unsupported = ['selection-background = #ff0000', 'font-size = 190', 'config-file = /missing',
208 'cursor-text = #000000', 'font-family = MissingFont', 'include = /missing']
209 dark.write_text(theme_text(DARK) + '\n'.join(unsupported) + '\n')
210 write_config(rig, 'theme = Dark Test\n')
211 rig.launch_gui([], 'gui-warnings'); specimen(rig, refs); colors(rig, DARK)
212 log = rig.gui_log.read_text()
213 for i, entry in enumerate(unsupported):
214 key = entry.split(' = ')[0]
215 expected_line = len(theme_text(DARK).splitlines()) + i + 1
216 matches = [line for line in log.splitlines() if key in line]
217 require(any(f'{dark}:{expected_line}' in line and 'ignored' in line for line in matches),
218 f'missing file:line unsupported warning: {log}')
219 rig.quit()
220 for bad, line in (('background = nope\n', 1), ('# heading\npalette = 256=112233\n', 2),
221 ('background = 123456\nbackground = 234567\n', 2),
222 ('palette = 1=123456\npalette = 1=654321\n', 2)):
223 dark.write_text(bad); rejected(rig, 'theme = Dark Test\n', dark, line)
224 rejected(rig, 'theme = Missing\n', themes / 'Missing', None)
225 for bad in ('theme = ../Dark Test\n', 'theme = nested/Name\n', 'background = #12345g\n', 'selection-background = #123456\n'):
226 rejected(rig, bad, cfg, 1)
227 write_config(rig, '')
228 env = rig.env.copy(); env.pop('MUXG_TEST_FIFO', None)
229 for flags in (['--background', 'wrong'], ['--foreground', '#12345g'],
230 ['--cursor-color', '12345'], ['--palette', '256=123456'], ['--palette', '1=nope']):
231 result = subprocess.run([rig.muxg, *flags], env=env, capture_output=True, timeout=5)
232 require(result.returncode == 2, f'invalid CLI color accepted: {flags}')
233 write_config(rig, 'bad config\n')
234 result = subprocess.run([rig.muxg, '--help'], env=rig.env, capture_output=True, timeout=5)
235 require(result.returncode == 0, 'invalid config prevents help')
236 rig.assert_cli_untouched()
237 rig.ok('HOME lookup, unsupported warnings, fatal malformed values/paths, unchanged workspace and help verified')
238 (rig.root / 'theme-config-result.json').write_text(json.dumps({'checks': rig.checkpoints, 'shell_ids': ids}, indent=2))
239 print('PASS: native theme config;', rig.root, flush=True)
240 finally:
241 rig.close()
242 if original_scale is not None: scale(original_scale)
243
244
245 if __name__ == '__main__':
246 main()