d0151836
feat: load Ghostty theme files for native appearance
a73x 2026-09-06 06:07
Commit message
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() | ||