58e66682
feat: centralize native colours with a warm theme trial
a73x 2026-09-06 05:16
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -754,3 +754,76 @@ acceptance of the ergonomic changes. | |||
| 754 | End together; keep save errors prominent in narrow panes. | 754 | End together; keep save errors prominent in narrow panes. |
| 755 | - [ ] A future recorder package should include its PTY registration and absolute | 755 | - [ ] A future recorder package should include its PTY registration and absolute |
| 756 | compositor address setup, avoiding the two fixture mistakes caught here. | 756 | compositor address setup, avoiding the two fixture mistakes caught here. |
| 757 | |||
| 758 | ## Native appearance — hardcoded theme — 2026-09-05 | ||
| 759 | |||
| 760 | The first appearance slice centralises native colours and selects a warm dark | ||
| 761 | trial theme across terminal defaults, the indexed palette, cursor, headers, | ||
| 762 | dividers, bell feedback, pickers, recovery menus and the empty workspace. Explicit | ||
| 763 | application RGB remains exact. The original appearance remains `theme.legacy` | ||
| 764 | for the eventual no-config default. Config, font selection and Nerd Font | ||
| 765 | verification are the next slice; ligatures require a later scope discussion. | ||
| 766 | |||
| 767 | The [appearance delivery record](docs/superpowers/plans/2026-09-05-native-appearance.md) | ||
| 768 | contains acceptance scenarios and evidence. Native text selection is not | ||
| 769 | implemented today, so this slice themes existing popup selection only. An early | ||
| 770 | brief incorrectly called the baseline cursor an underline; independent review | ||
| 771 | checked the baseline and preserved its actual full-cell block geometry. | ||
| 772 | |||
| 773 | Opening inspection found no necessary refactor outside the theme extraction. | ||
| 774 | Luna implemented native theme/painter changes; Terra reviewed independently; | ||
| 775 | root owned real integration, validation, recording and documentation. Recorded | ||
| 776 | turn-context metadata confirmed the requested models. Review caught an unused | ||
| 777 | bell foreground and redundant chrome fields, which were fixed before delivery. | ||
| 778 | Closing review found no remaining duplicate colour paths or new configuration | ||
| 779 | scaffolding. The trial's source selection is intentional until config arrives. | ||
| 780 | |||
| 781 | The new real-framebuffer acceptance uses three panes, including off-origin | ||
| 782 | content, independently specified expected colours and nonforcing retained-frame | ||
| 783 | reads. It covers all 16 ANSI entries, extended indices, inverse defaults, | ||
| 784 | explicit RGB, cursor/focus/dividers, menu rows, bell expiry, empty workspace, | ||
| 785 | independent PTY dimensions and surviving detached sessions. An old native gate | ||
| 786 | assumed ANSI red was highly saturated; three glyph-placement fixtures now ask | ||
| 787 | for explicit RGB, keeping colour policy in the separate appearance oracle. | ||
| 788 | |||
| 789 | Release native units/core, native integration, full CI, offscreen and 200% | ||
| 790 | Wayland appearance, and real Wayland 200% → 100% → 150% → 200% scale transitions | ||
| 791 | passed. Offscreen raw-output responsiveness passed. NVIDIA and AMD headless | ||
| 792 | compositor comparison details, including the NVIDIA frame-budget miss, are | ||
| 793 | retained in the delivery record. The user correctly challenged treating an AMD | ||
| 794 | result as sufficient for their NVIDIA setup. A headless render device needs no | ||
| 795 | connected display, but cross-device results do not establish NVIDIA performance. | ||
| 796 | Root added a matched baseline/theme comparison on the same NVIDIA compositor; | ||
| 797 | one theme run passed and another exceeded the unchanged 20 ms gate. CPU paint | ||
| 798 | timings overlapped; no consistent theme penalty was established, but the strict | ||
| 799 | NVIDIA timing result remains unresolved. The sprint is not marked accepted. | ||
| 800 | No physical display configuration was | ||
| 801 | changed and no new macOS validation is claimed. | ||
| 802 | |||
| 803 | The 27.2-second continuous recording shows the actual GUI on an isolated NVIDIA | ||
| 804 | Sway compositor at 200%, with no audio or restart cuts. Root checked the preview | ||
| 805 | and decoded popup frame. The video, screenshot, scripts, logs, reports and | ||
| 806 | available cumulative agent counters live under `dist/appearance-slice1/` in this | ||
| 807 | worktree. Counters identify cached input as included, and are neither an | ||
| 808 | incremental slice cost nor a monetary bill. No external publication occurred. | ||
| 809 | User demo acceptance remains pending. | ||
| 810 | |||
| 811 | ### Retained limits and next actions | ||
| 812 | |||
| 813 | - [ ] Record the user's review of the appearance demo separately from test | ||
| 814 | success, before moving to config/fonts. | ||
| 815 | - [ ] In the config slice, restore `theme.legacy` as the no-config default and | ||
| 816 | explicitly select a trial theme in appearance fixtures. Keep CLI precedence | ||
| 817 | and font-size units consistent with the agreed Ghostty-compatible subset. | ||
| 818 | - [ ] Verify installed Nerd Font Mono symbols in the font slice; font fallback | ||
| 819 | and cross-cell ligatures are not implied by choosing a family. | ||
| 820 | - [ ] Next renderer/offscreen-fixture work: investigate unpainted 0xaa capture | ||
| 821 | regions after offscreen surface growth if dynamic resizing is needed there. | ||
| 822 | The final appearance specimen fits baseline panes; real Wayland resizing | ||
| 823 | passed. The cause of the initial offscreen observation is unproven. | ||
| 824 | - [ ] Future compositor fixtures should set the required geometry at mapping. | ||
| 825 | The stress fixture expects 960×600; a demo mapping rule for 1000×650 raced an | ||
| 826 | early resize command and stopped the first stress attempt before workload. | ||
| 827 | - [ ] Keep NVIDIA as the relevant performance validation target for this user. | ||
| 828 | The stress frame metric includes test readback and vsync; preserve failed | ||
| 829 | results and distinguish a matched baseline comparison from an absolute pass. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1196,8 +1196,12 @@ pub fn build(b: *std.Build) void { | |||
| 1196 | native_lifecycle.addArtifactArg(mux_exe); | 1196 | native_lifecycle.addArtifactArg(mux_exe); |
| 1197 | native_lifecycle.addArtifactArg(muxg_exe); | 1197 | native_lifecycle.addArtifactArg(muxg_exe); |
| 1198 | native_lifecycle.step.dependOn(&native_resize.step); | 1198 | native_lifecycle.step.dependOn(&native_resize.step); |
| 1199 | const native_theme = b.addSystemCommand(&.{ "python3", "-B", "test/native_theme.py" }); | ||
| 1200 | native_theme.addArtifactArg(mux_exe); | ||
| 1201 | native_theme.addArtifactArg(muxg_exe); | ||
| 1202 | native_theme.step.dependOn(&native_lifecycle.step); | ||
| 1199 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1203 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1200 | native_e2e_step.dependOn(&native_lifecycle.step); | 1204 | native_e2e_step.dependOn(&native_theme.step); |
| 1201 | 1205 | ||
| 1202 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | 1206 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale |
| 1203 | // Debug daemon gives misleading latency numbers under raw terminal output. | 1207 | // Debug daemon gives misleading latency numbers under raw terminal output. |
docs/superpowers/plans/2026-09-05-native-appearance.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,162 @@ | |||
| 1 | # Native appearance — incremental delivery | ||
| 2 | |||
| 3 | The user wants personalisation of muxg's fonts and colours, keeping its current | ||
| 4 | layout and navigation. Configuration takes effect on the next client launch; | ||
| 5 | hot reload is excluded because restarting the client is cheap and preserves | ||
| 6 | daemon sessions. Terminal CLI and browser appearance are outside this work. | ||
| 7 | |||
| 8 | ## Agreed slices | ||
| 9 | |||
| 10 | 1. **Theme the existing native UI with a hardcoded palette.** Establish one | ||
| 11 | reusable theme value and demonstrate a visibly different, cohesive appearance | ||
| 12 | before building configuration. This is the active sprint. | ||
| 13 | 2. **Config and fonts.** Read `$XDG_CONFIG_HOME/mux/config`, falling back to | ||
| 14 | `~/.config/mux/config`; support Ghostty-compatible font-family and font-size | ||
| 15 | (points), explicit CLI precedence, actionable diagnostics, and installed | ||
| 16 | Nerd Font Mono icons verified in real output. Font fallback is separate scope. | ||
| 17 | 3. **Ghostty theme files.** Load a theme by absolute path or name under mux's | ||
| 18 | themes directory. Defaults < theme < explicit config < CLI. Preserve today's | ||
| 19 | appearance when no config exists in the completed feature. Report unsupported | ||
| 20 | appearance keys; document the supported subset. Demonstrate dark and light | ||
| 21 | themes and an explicit colour override. | ||
| 22 | 4. **Ligatures: discussion pending.** Discuss scope and acceptance with the user | ||
| 23 | when reaching this candidate sprint. Existing shaping is per cell; cross-cell | ||
| 24 | ligatures are not implied by font selection. No ligature implementation is | ||
| 25 | authorized for the active sprint. | ||
| 26 | |||
| 27 | Each slice ends with validated work and an actual application demo. User demo | ||
| 28 | acceptance is recorded separately from implementation and test results. | ||
| 29 | |||
| 30 | ## Active sprint: hardcoded theme | ||
| 31 | |||
| 32 | Use a warm dark trial theme with cream text, teal focus and amber bell feedback. | ||
| 33 | The original theme remains a named value for the later no-config default. The | ||
| 34 | trial is selected in source for this slice; there is no temporary CLI or config | ||
| 35 | interface to maintain. Theme extraction is the deliverable itself, and opening | ||
| 36 | inspection found no additional refactor needed before it. | ||
| 37 | |||
| 38 | Observable acceptance: | ||
| 39 | |||
| 40 | - Default terminal foreground/background and indexed colours use the theme; | ||
| 41 | explicit application RGB values remain exact. Default inverse and extended | ||
| 42 | indexed colours preserve their meaning. | ||
| 43 | - Multiple panes show themed focused/inactive headers, dividers and cursor, | ||
| 44 | with no geometry, focus, input or lifecycle changes. | ||
| 45 | - Host/session pickers, recovery menus and the empty workspace use themed | ||
| 46 | backgrounds, text and selected rows; bell feedback remains visible. | ||
| 47 | - The existing native cursor retains its block shape. Native terminal text | ||
| 48 | selection does not yet exist; only existing popup selection is in this slice. | ||
| 49 | Do not claim a text-selection demonstration or add unused selection settings. | ||
| 50 | - Verify the real framebuffer, including off-origin panes and explicit RGB | ||
| 51 | output; record the running application on an isolated compositor. | ||
| 52 | - Run repository and relevant native delivery gates on frozen source. Record | ||
| 53 | platform/renderer limits, failures and retained debt honestly. | ||
| 54 | |||
| 55 | Pair: Luna implementation owns native theme and painter changes; Terra reviews | ||
| 56 | adversarially; root owns integration probes, independent validation, recording, | ||
| 57 | documentation and commits. Requested models were confirmed in recorded session | ||
| 58 | turn-context metadata at startup. | ||
| 59 | |||
| 60 | ## Delivery evidence | ||
| 61 | |||
| 62 | Implementation and adversarial review are complete. `src/gui/theme.zig` owns | ||
| 63 | native defaults, all 256 indexed entries, cursor and chrome colours. The painter | ||
| 64 | accepts one theme plus explicit foreground/background overrides for chrome rows; | ||
| 65 | application RGB remains independent. No native input or lifecycle code changed. | ||
| 66 | |||
| 67 | Closing cleanup removed unused generic chrome fields and wired bell foreground | ||
| 68 | as well as background. Review also corrected an early brief's cursor description: | ||
| 69 | the baseline is a full-cell block, and its geometry is preserved. Three older | ||
| 70 | red-glyph fixtures now request explicit RGB instead of assuming ANSI red must be | ||
| 71 | saturated enough to pass their placement thresholds. | ||
| 72 | |||
| 73 | Validation logs are retained under `dist/appearance-slice1/` in this worktree: | ||
| 74 | |||
| 75 | - `build-final.log`: pinned ReleaseSafe daemon/GUI pair and native units passed. | ||
| 76 | - `native-core.log`: ReleaseSafe native core passed. | ||
| 77 | - `ci.log`: full `make ci` passed, including checks, terminal integration, | ||
| 78 | agent scenarios and throughput. `check-final.log` records the final precommit | ||
| 79 | check after fixture and documentation updates. | ||
| 80 | - `native-e2e.log`: viewer, tiling, picker, resize, lifecycle and the new | ||
| 81 | `test/native_theme.py` framebuffer acceptance all passed. | ||
| 82 | - `theme-offscreen.log` and `theme-wayland.log`: three real panes, all ANSI | ||
| 83 | entries, extended indices, inverse defaults, explicit RGB, cursor, focused and | ||
| 84 | inactive headers, dividers, host/recovery menus, bell expiry and empty workspace | ||
| 85 | passed. Retained-frame capture does not trigger painting. PTY dimensions and | ||
| 86 | surviving sessions are checked independently through the existing rig. | ||
| 87 | - `scale-wayland.log`: 200% → 100% → 150% → 200% scale transitions, stale-drag | ||
| 88 | cancellation, nested resize, independent PTY dimensions and pixels passed. | ||
| 89 | - `stress-offscreen.log`: raw-output responsiveness passed. Wayland comparison | ||
| 90 | results are recorded below; no performance improvement is claimed. | ||
| 91 | |||
| 92 | The 27.2-second `native-appearance-slice1.mp4` is a continuous recording of the | ||
| 93 | actual isolated Sway window at 200%, encoded H.264/yuv420p at 1100×700, without | ||
| 94 | audio or restart cuts. It shows multiple sessions, the indexed palette, explicit | ||
| 95 | RGB, focus, typing, picker and recovery highlights, bell feedback, resizing and | ||
| 96 | empty workspace. `native-appearance-slice1.png` is the retained preview. Root | ||
| 97 | inspected the preview and a decoded popup frame. The recording used the NVIDIA | ||
| 98 | compositor; its renderer log is retained as `compositor-nvidia.log`. The recorder | ||
| 99 | stopped before closing the owned GUI. No external publication was performed. | ||
| 100 | |||
| 101 | ### Limits and follow-up | ||
| 102 | |||
| 103 | The initial native gate failed because its ANSI-red threshold assumed the old | ||
| 104 | palette; `native-e2e-before-fixture-fix.log` is retained. The corrected fixture | ||
| 105 | tests RGB glyph placement, while the new theme probe checks indexed colours. | ||
| 106 | |||
| 107 | An initial 14-row specimen resized the offscreen window from 960×600 to 1100×700; | ||
| 108 | upper capture regions read 0xaa. The cause is unproven. The final appearance | ||
| 109 | probe fits baseline 12-row panes; real Wayland resizing is covered separately. | ||
| 110 | Next renderer/offscreen-fixture work should investigate this if it needs growing | ||
| 111 | offscreen surfaces. Do not infer a Wayland resize failure from that observation. | ||
| 112 | |||
| 113 | The first Wayland stress setup raced a compositor mapping rule that chose | ||
| 114 | 1000×650 against the lifecycle fixture's 960×600 expectation. The owned mapping | ||
| 115 | rule now uses the fixture's intended size. That pre-exercise failure is retained | ||
| 116 | as `stress-wayland-mapping-failure.log`. | ||
| 117 | |||
| 118 | On NVIDIA Wayland, the stress interactions completed but final frame p99 was | ||
| 119 | 20,993 us against a 20,000 us budget, in the combined draw/readback/swap stage; this is a | ||
| 120 | failed budget result, retained as `stress-wayland-nvidia.log`. Offscreen passed. | ||
| 121 | The AMD comparison uses the same mode and scale, with CI and recording stopped. | ||
| 122 | It passed with final frame p99 16,022 us (`stress-wayland-amd.log`); renderer | ||
| 123 | provenance is retained in `compositor-amd.log`. Both reports and the offscreen | ||
| 124 | report are copied beside the logs. Input timings are sampled upper bounds with | ||
| 125 | 5 ms polling. No new macOS validation is claimed. | ||
| 126 | |||
| 127 | ### Matched NVIDIA comparison after user feedback | ||
| 128 | |||
| 129 | The user's display uses NVIDIA; the AMD run was only a headless cross-device | ||
| 130 | comparison and does not establish performance on that display. Root built the | ||
| 131 | unchanged `ad746c0f` baseline in a separate checkout and ran the same stress | ||
| 132 | script twice per version, interleaved on one owned RTX 3080 compositor. Both | ||
| 133 | pairs came from pinned ReleaseSafe builds. Output mode, scale, fixture and | ||
| 134 | logging were held constant; CI and recording had finished. Reports and binary | ||
| 135 | hashes are retained in `nvidia-comparison.json` and its per-run logs/JSON files. | ||
| 136 | |||
| 137 | | Run | Before reopen p99 (us) | Final p99 (us) | Input upper bound (ms) | Strict gate | | ||
| 138 | | --- | ---: | ---: | ---: | --- | | ||
| 139 | | Baseline 1 | 19,904 | 19,878 | 70.2 | pass | | ||
| 140 | | Theme 1 | 18,923 | 18,886 | 70.2 | pass | | ||
| 141 | | Baseline 2 | 18,822 | 19,935 | 70.3 | pass | | ||
| 142 | | Theme 2 | 20,136 | 20,884 | 60.4 | fail | | ||
| 143 | |||
| 144 | CPU rebuild p99 overlapped (225–278 us baseline, 225–269 us theme). The | ||
| 145 | combined draw/readback/swap stage was lower for the first theme pair and higher | ||
| 146 | for the second; no consistent appearance penalty was established. That does | ||
| 147 | **not** turn the above-budget NVIDIA result into a pass. The strict NVIDIA | ||
| 148 | stress gate remains unresolved, with no budget or refresh-rate changes made. | ||
| 149 | Input observations remained inside the independent 250 ms bound. The frame | ||
| 150 | metric includes test framebuffer readback and vsync and does not isolate driver, | ||
| 151 | compositor or render cost. Headless evidence does not establish physical-display | ||
| 152 | performance. This limit needs explicit resolution or scope disposition before | ||
| 153 | calling the whole sprint accepted; the appearance implementation and functional | ||
| 154 | demo are ready for review. | ||
| 155 | |||
| 156 | The owned compositor and fixture processes were stopped; the temporary baseline | ||
| 157 | checkout was removed after its reports and hashes were retained. | ||
| 158 | |||
| 159 | User demo acceptance is pending. Config/fonts and Ghostty file loading have not | ||
| 160 | started. When adding config, restore `theme.legacy` as the no-config default and | ||
| 161 | make the theme probe explicitly select its trial fixture. Discuss ligatures only | ||
| 162 | after the preceding slices, as the user requested. | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const interaction = native_core.interaction; | |||
| 12 | const font = @import("font.zig"); | 12 | const font = @import("font.zig"); |
| 13 | const atlas = @import("atlas.zig"); | 13 | const atlas = @import("atlas.zig"); |
| 14 | const quads = @import("quads.zig"); | 14 | const quads = @import("quads.zig"); |
| 15 | const theme_mod = @import("theme.zig"); | ||
| 15 | const gl = @import("gl.zig"); | 16 | const gl = @import("gl.zig"); |
| 16 | const bench = @import("bench.zig"); | 17 | const bench = @import("bench.zig"); |
| 17 | 18 | ||
| @@ -444,6 +445,7 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { | |||
| 444 | } | 445 | } |
| 445 | 446 | ||
| 446 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | 447 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { |
| 448 | const appearance = &theme_mod.trial; | ||
| 447 | var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null; | 449 | var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null; |
| 448 | defer if (store) |*s| s.deinit(); | 450 | defer if (store) |*s| s.deinit(); |
| 449 | var load_notice: [256]u8 = @splat(0); | 451 | var load_notice: [256]u8 = @splat(0); |
| @@ -607,13 +609,13 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 607 | } | 609 | } |
| 608 | const had_blink = visible_blink; | 610 | const had_blink = visible_blink; |
| 609 | visible_blink = false; | 611 | visible_blink = false; |
| 610 | const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase }; | 612 | const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase, .theme = appearance }; |
| 611 | for (events.ui.layout.items(), 0..) |p, i| { | 613 | for (events.ui.layout.items(), 0..) |p, i| { |
| 612 | if (p.visible.w == 0 or p.visible.h == 0) continue; | 614 | if (p.visible.w == 0 or p.visible.h == 0) continue; |
| 613 | const live = rt.get(p.id).?; | 615 | const live = rt.get(p.id).?; |
| 614 | const grid = live.snapshot; | 616 | const grid = live.snapshot; |
| 615 | // Opaque content rectangles leave the clear color in dividers. | 617 | // Opaque content rectangles leave the clear color in dividers. |
| 616 | try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(p.content.x), @floatFromInt(p.content.y), @floatFromInt(p.content.w), @floatFromInt(p.content.h), 0x101010ff)); | 618 | try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(p.content.x), @floatFromInt(p.content.y), @floatFromInt(p.content.w), @floatFromInt(p.content.h), appearance.terminal_bg)); |
| 617 | var ctx = base_ctx; | 619 | var ctx = base_ctx; |
| 618 | ctx.x0 = @floatFromInt(p.content.x); | 620 | ctx.x0 = @floatFromInt(p.content.x); |
| 619 | ctx.y0 = @floatFromInt(p.content.y); | 621 | ctx.y0 = @floatFromInt(p.content.y); |
| @@ -624,9 +626,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 624 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); | 626 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); |
| 625 | ctx.x0 = @floatFromInt(p.header.x); | 627 | ctx.x0 = @floatFromInt(p.header.x); |
| 626 | ctx.y0 = @floatFromInt(p.header.y); | 628 | ctx.y0 = @floatFromInt(p.header.y); |
| 627 | ctx.default_bg = if (rt.workspace.tab().focus == p.id) 0x304860ff else 0x24282cff; | 629 | const focused = rt.workspace.tab().focus == p.id; |
| 628 | if (live.bell_until != 0) ctx.default_bg = 0x705020ff; | 630 | const bell = live.bell_until != 0; |
| 629 | try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(p.header.w), @floatFromInt(p.header.h), ctx.default_bg)); | 631 | ctx.fg = if (bell) appearance.bell_header_fg else if (focused) appearance.chrome_focus_fg else appearance.chrome_unfocused_fg; |
| 632 | ctx.bg = if (bell) appearance.bell_header_bg else if (focused) appearance.chrome_focus_bg else appearance.chrome_unfocused_bg; | ||
| 633 | try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(p.header.w), @floatFromInt(p.header.h), ctx.bg.?)); | ||
| 630 | const header_start = lists.foregrounds.items.len; | 634 | const header_start = lists.foregrounds.items.len; |
| 631 | var header_row = headers[i].row(); | 635 | var header_row = headers[i].row(); |
| 632 | _ = try quads.rowInstances(&lists, alloc, &header_row, @intCast(header_row.cells.len), 0, 0, ctx); | 636 | _ = try quads.rowInstances(&lists, alloc, &header_row, @intCast(header_row.cells.len), 0, 0, ctx); |
| @@ -646,7 +650,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 646 | timing.atlas_us = bench.usSince(&timer); | 650 | timing.atlas_us = bench.usSince(&timer); |
| 647 | renderer.uploadInstances(instances.items); | 651 | renderer.uploadInstances(instances.items); |
| 648 | timing.upload_us = bench.usSince(&timer); | 652 | timing.upload_us = bench.usSince(&timer); |
| 649 | renderer.draw(instances.items.len, events.ui.fb_w, events.ui.fb_h, 0x687888ff); | 653 | renderer.draw(instances.items.len, events.ui.fb_w, events.ui.fb_h, appearance.divider); |
| 650 | if (hook) |*h| if (h.capture != null or h.retain_frame) { | 654 | if (hook) |*h| if (h.capture != null or h.retain_frame) { |
| 651 | const pixels = try renderer.readPixels(alloc, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h)); | 655 | const pixels = try renderer.readPixels(alloc, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h)); |
| 652 | var owned = true; | 656 | var owned = true; |
| @@ -1317,13 +1321,15 @@ const PopupFrame = struct { | |||
| 1317 | } | 1321 | } |
| 1318 | fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { | 1322 | fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { |
| 1319 | if (self.len == 0) return; | 1323 | if (self.len == 0) return; |
| 1320 | try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), 0x1c2632ff)); | 1324 | try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), base.theme.modal_bg)); |
| 1321 | for (self.lines[0..self.len], 0..) |*line, i| { | 1325 | for (self.lines[0..self.len], 0..) |*line, i| { |
| 1322 | var ctx = base; | 1326 | var ctx = base; |
| 1323 | ctx.x0 = @floatFromInt(self.rect.x); | 1327 | ctx.x0 = @floatFromInt(self.rect.x); |
| 1324 | ctx.y0 = @floatFromInt(self.rect.y + @as(u32, @intCast(i)) * base.cell_h); | 1328 | ctx.y0 = @floatFromInt(self.rect.y + @as(u32, @intCast(i)) * base.cell_h); |
| 1325 | ctx.default_bg = if (self.selected_line == i) 0x435e75ff else 0x1c2632ff; | 1329 | const selected = self.selected_line == i; |
| 1326 | if (self.selected_line == i) try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(self.rect.w), @floatFromInt(base.cell_h), 0x435e75ff)); | 1330 | ctx.fg = if (selected) base.theme.modal_selected_fg else base.theme.modal_fg; |
| 1331 | ctx.bg = if (selected) base.theme.modal_selected_bg else base.theme.modal_bg; | ||
| 1332 | if (selected) try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(self.rect.w), @floatFromInt(base.cell_h), base.theme.modal_selected_bg)); | ||
| 1327 | var row = line.row(); | 1333 | var row = line.row(); |
| 1328 | _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx); | 1334 | _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx); |
| 1329 | } | 1335 | } |
src/gui/native.zig
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ pub const bench = @import("bench.zig"); | |||
| 17 | pub const atlas = @import("atlas.zig"); | 17 | pub const atlas = @import("atlas.zig"); |
| 18 | pub const font = @import("font.zig"); | 18 | pub const font = @import("font.zig"); |
| 19 | pub const quads = @import("quads.zig"); | 19 | pub const quads = @import("quads.zig"); |
| 20 | pub const theme = @import("theme.zig"); | ||
| 20 | pub const gl = @import("gl.zig"); | 21 | pub const gl = @import("gl.zig"); |
| 21 | 22 | ||
| 22 | // Compatibility aliases for callers that previously reached these through | 23 | // Compatibility aliases for callers that previously reached these through |
| @@ -40,5 +41,6 @@ test { | |||
| 40 | _ = atlas; | 41 | _ = atlas; |
| 41 | _ = font; | 42 | _ = font; |
| 42 | _ = quads; | 43 | _ = quads; |
| 44 | _ = theme; | ||
| 43 | _ = gl; | 45 | _ = gl; |
| 44 | } | 46 | } |
src/gui/quads.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ const term = @import("term"); | |||
| 4 | const grid = term.grid; | 4 | const grid = term.grid; |
| 5 | const proto = term.protocol; | 5 | const proto = term.protocol; |
| 6 | const atlas = @import("atlas.zig"); | 6 | const atlas = @import("atlas.zig"); |
| 7 | const theme_mod = @import("theme.zig"); | ||
| 7 | pub const Instance = extern struct { | 8 | pub const Instance = extern struct { |
| 8 | x: f32, | 9 | x: f32, |
| 9 | y: f32, | 10 | y: f32, |
| @@ -32,24 +33,10 @@ pub const Lists = struct { | |||
| 32 | try out.appendSlice(a, s.foregrounds.items); | 33 | try out.appendSlice(a, s.foregrounds.items); |
| 33 | } | 34 | } |
| 34 | }; | 35 | }; |
| 35 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, default_fg: u32 = 0xd0d0d0ff, default_bg: u32 = 0x101010ff }; | 36 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, theme: *const theme_mod.Theme = &theme_mod.legacy, fg: ?u32 = null, bg: ?u32 = null }; |
| 36 | const ansi16 = [16]u32{ 0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff, 0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff }; | 37 | pub fn rgbaOf(c: u32, d: u32, appearance: *const theme_mod.Theme) u32 { |
| 37 | fn rgb(r: u8, g: u8, b: u8) u32 { | ||
| 38 | return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff; | ||
| 39 | } | ||
| 40 | fn palette(i: u8) u32 { | ||
| 41 | if (i < 16) return ansi16[i]; | ||
| 42 | if (i < 232) { | ||
| 43 | const n = i - 16; | ||
| 44 | const s = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff }; | ||
| 45 | return rgb(s[n / 36], s[(n / 6) % 6], s[n % 6]); | ||
| 46 | } | ||
| 47 | const g: u8 = 8 + 10 * (i - 232); | ||
| 48 | return rgb(g, g, g); | ||
| 49 | } | ||
| 50 | pub fn rgbaOf(c: u32, d: u32) u32 { | ||
| 51 | return switch (c >> 24) { | 38 | return switch (c >> 24) { |
| 52 | 1 => palette(@intCast(c & 0xff)), | 39 | 1 => appearance.palette[@intCast(c & 0xff)], |
| 53 | 2 => (c << 8) | 0xff, | 40 | 2 => (c << 8) | 0xff, |
| 54 | else => d, | 41 | else => d, |
| 55 | }; | 42 | }; |
| @@ -98,7 +85,7 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 98 | if (cell.wide == .spacer_tail) continue; | 85 | if (cell.wide == .spacer_tail) continue; |
| 99 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 86 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| 100 | const inv = cell.style.flags & (1 << 4) != 0; | 87 | const inv = cell.style.flags & (1 << 4) != 0; |
| 101 | const bg = if (inv) rgbaOf(cell.style.fg, ctx.default_fg) else rgbaOf(cell.style.bg, ctx.default_bg); | 88 | const bg = if (inv) rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme) else rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme); |
| 102 | if (inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); | 89 | if (inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); |
| 103 | } | 90 | } |
| 104 | for (row.cells[0..n], 0..) |cell, x| { | 91 | for (row.cells[0..n], 0..) |cell, x| { |
| @@ -108,7 +95,7 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 108 | if (flags & (1 << 3) != 0 and flags & (1 << 5) == 0 and (cell.text_len > 0 or decorated)) has_blink = true; | 95 | if (flags & (1 << 3) != 0 and flags & (1 << 5) == 0 and (cell.text_len > 0 or decorated)) has_blink = true; |
| 109 | if (flags & (1 << 5) != 0 or (flags & (1 << 3) != 0 and !ctx.blink_visible)) continue; | 96 | if (flags & (1 << 5) != 0 or (flags & (1 << 3) != 0 and !ctx.blink_visible)) continue; |
| 110 | const inv = flags & (1 << 4) != 0; | 97 | const inv = flags & (1 << 4) != 0; |
| 111 | var fg = if (inv) rgbaOf(cell.style.bg, ctx.default_bg) else rgbaOf(cell.style.fg, ctx.default_fg); | 98 | var fg = if (inv) rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme) else rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme); |
| 112 | if (flags & (1 << 2) != 0) fg = (fg & 0xffffff00) | 0x80; | 99 | if (flags & (1 << 2) != 0) fg = (fg & 0xffffff00) | 0x80; |
| 113 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; | 100 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; |
| 114 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 101 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| @@ -138,20 +125,42 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 138 | } | 125 | } |
| 139 | if (flags & (1 << 6) != 0) try out.foregrounds.append(a, solid(left, top + ch / 2, span, @max(ch / 16, 1), fg)); | 126 | if (flags & (1 << 6) != 0) try out.foregrounds.append(a, solid(left, top + ch / 2, span, @max(ch / 16, 1), fg)); |
| 140 | if (flags & (1 << 7) != 0) try out.foregrounds.append(a, solid(left, top, span, @max(ch / 16, 1), fg)); | 127 | if (flags & (1 << 7) != 0) try out.foregrounds.append(a, solid(left, top, span, @max(ch / 16, 1), fg)); |
| 141 | try decoration(&out.foregrounds, a, left, top, span, ch, rgbaOf(cell.style.ul, fg), (flags >> 8) & 7); | 128 | try decoration(&out.foregrounds, a, left, top, span, ch, rgbaOf(cell.style.ul, fg, ctx.theme), (flags >> 8) & 7); |
| 142 | } | 129 | } |
| 143 | return has_blink; | 130 | return has_blink; |
| 144 | } | 131 | } |
| 145 | pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance { | 132 | pub fn cursorInstance(x: u16, y: u16, ctx: Ctx) Instance { |
| 146 | return solid(ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)), ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)), @floatFromInt(ctx.cell_w), @floatFromInt(ctx.cell_h), ctx.default_fg); | 133 | return solid(ctx.x0 + @as(f32, @floatFromInt(x)) * @as(f32, @floatFromInt(ctx.cell_w)), ctx.y0 + @as(f32, @floatFromInt(y)) * @as(f32, @floatFromInt(ctx.cell_h)), @floatFromInt(ctx.cell_w), @floatFromInt(ctx.cell_h), ctx.theme.cursor); |
| 147 | } | 134 | } |
| 148 | 135 | ||
| 149 | test "palette and cursor preserve origin" { | 136 | test "palette and cursor preserve origin" { |
| 150 | try std.testing.expectEqual(@as(u32, 0xcc0000ff), rgbaOf(proto.colorPalette(1), 0)); | 137 | try std.testing.expectEqual(@as(u32, 0xcc0000ff), rgbaOf(proto.colorPalette(1), 0, &theme_mod.legacy)); |
| 151 | const dummy = Ctx{ .cell_w = 8, .cell_h = 16, .ascent = 12, .x0 = 3, .y0 = 4, .atlas_w = 1, .atlas_h = 1, .glyphs = undefined }; | 138 | const dummy = Ctx{ .cell_w = 8, .cell_h = 16, .ascent = 12, .x0 = 3, .y0 = 4, .atlas_w = 1, .atlas_h = 1, .glyphs = undefined }; |
| 152 | const q = cursorInstance(2, 1, dummy); | 139 | const q = cursorInstance(2, 1, dummy); |
| 153 | try std.testing.expectEqual(@as(f32, 19), q.x); | 140 | try std.testing.expectEqual(@as(f32, 19), q.x); |
| 154 | try std.testing.expectEqual(@as(f32, 20), q.y); | 141 | try std.testing.expectEqual(@as(f32, 20), q.y); |
| 142 | try std.testing.expectEqual(@as(f32, 16), q.h); | ||
| 143 | try std.testing.expectEqual(theme_mod.legacy.cursor, q.rgba); | ||
| 144 | } | ||
| 145 | |||
| 146 | test "theme resolves indexed, default, RGB and inverse colours independently" { | ||
| 147 | var appearance = theme_mod.trial; | ||
| 148 | appearance.palette[1] = 0x010203ff; | ||
| 149 | appearance.palette[200] = 0xa0b0c0ff; | ||
| 150 | try std.testing.expectEqual(@as(u32, 0x010203ff), rgbaOf(proto.colorPalette(1), appearance.terminal_fg, &appearance)); | ||
| 151 | try std.testing.expectEqual(@as(u32, 0xa0b0c0ff), rgbaOf(proto.colorPalette(200), appearance.terminal_fg, &appearance)); | ||
| 152 | try std.testing.expectEqual(appearance.terminal_bg, rgbaOf(proto.color_none, appearance.terminal_bg, &appearance)); | ||
| 153 | try std.testing.expectEqual(@as(u32, 0x090807ff), rgbaOf(proto.colorRgb(9, 8, 7), appearance.terminal_fg, &appearance)); | ||
| 154 | |||
| 155 | var cells = [_]grid.Cell{.{ .text_len = 1, .style = .{ .flags = 1 << 4, .fg = proto.colorRgb(9, 8, 7), .bg = proto.colorPalette(200) } }}; | ||
| 156 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("x"), .capacity = 1 } }; | ||
| 157 | var lists: Lists = .{}; | ||
| 158 | defer lists.deinit(std.testing.allocator); | ||
| 159 | var ctx = testCtx(true); | ||
| 160 | ctx.theme = &appearance; | ||
| 161 | _ = try rowInstances(&lists, std.testing.allocator, &row, 1, 0, 0, ctx); | ||
| 162 | try std.testing.expectEqual(@as(u32, 0xa0b0c0ff), lists.foregrounds.items[0].rgba); | ||
| 163 | try std.testing.expectEqual(@as(u32, 0x090807ff), lists.backgrounds.items[0].rgba); | ||
| 155 | } | 164 | } |
| 156 | 165 | ||
| 157 | const Fake = struct { | 166 | const Fake = struct { |
src/gui/theme.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,104 @@ | |||
| 1 | //! Native appearance tokens and indexed terminal palette. | ||
| 2 | //! | ||
| 3 | //! A theme owns native defaults and chrome colours. The wire format still | ||
| 4 | //! carries terminal colours as tagged values; indexed entries use this palette | ||
| 5 | //! while explicit application RGB colours retain their values. | ||
| 6 | const std = @import("std"); | ||
| 7 | |||
| 8 | pub const Theme = struct { | ||
| 9 | terminal_fg: u32, | ||
| 10 | terminal_bg: u32, | ||
| 11 | cursor: u32, | ||
| 12 | palette: [256]u32, | ||
| 13 | chrome_focus_fg: u32, | ||
| 14 | chrome_focus_bg: u32, | ||
| 15 | chrome_unfocused_fg: u32, | ||
| 16 | chrome_unfocused_bg: u32, | ||
| 17 | bell_header_fg: u32, | ||
| 18 | bell_header_bg: u32, | ||
| 19 | modal_fg: u32, | ||
| 20 | modal_bg: u32, | ||
| 21 | modal_selected_fg: u32, | ||
| 22 | modal_selected_bg: u32, | ||
| 23 | divider: u32, | ||
| 24 | }; | ||
| 25 | |||
| 26 | fn rgb(r: u8, g: u8, b: u8) u32 { | ||
| 27 | return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff; | ||
| 28 | } | ||
| 29 | |||
| 30 | fn generatedPalette(ansi: [16]u32) [256]u32 { | ||
| 31 | var out: [256]u32 = undefined; | ||
| 32 | @memcpy(out[0..16], ansi[0..]); | ||
| 33 | for (16..232) |i| { | ||
| 34 | const n = i - 16; | ||
| 35 | const steps = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff }; | ||
| 36 | out[i] = rgb(steps[n / 36], steps[(n / 6) % 6], steps[n % 6]); | ||
| 37 | } | ||
| 38 | for (232..256) |i| { | ||
| 39 | const grey: u8 = @intCast(8 + 10 * (i - 232)); | ||
| 40 | out[i] = rgb(grey, grey, grey); | ||
| 41 | } | ||
| 42 | return out; | ||
| 43 | } | ||
| 44 | |||
| 45 | const legacy_ansi = [16]u32{ | ||
| 46 | 0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff, | ||
| 47 | 0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff, | ||
| 48 | 0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff, | ||
| 49 | 0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff, | ||
| 50 | }; | ||
| 51 | |||
| 52 | /// The palette and chrome used before appearance was centralised. Keep this | ||
| 53 | /// named value so a later configuration slice can make it selectable. | ||
| 54 | pub const legacy: Theme = .{ | ||
| 55 | .terminal_fg = 0xd0d0d0ff, | ||
| 56 | .terminal_bg = 0x101010ff, | ||
| 57 | .cursor = 0xd0d0d0ff, | ||
| 58 | .palette = generatedPalette(legacy_ansi), | ||
| 59 | .chrome_focus_fg = 0xd0d0d0ff, | ||
| 60 | .chrome_focus_bg = 0x304860ff, | ||
| 61 | .chrome_unfocused_fg = 0xd0d0d0ff, | ||
| 62 | .chrome_unfocused_bg = 0x24282cff, | ||
| 63 | .bell_header_fg = 0xd0d0d0ff, | ||
| 64 | .bell_header_bg = 0x705020ff, | ||
| 65 | .modal_fg = 0xd0d0d0ff, | ||
| 66 | .modal_bg = 0x1c2632ff, | ||
| 67 | .modal_selected_fg = 0xd0d0d0ff, | ||
| 68 | .modal_selected_bg = 0x435e75ff, | ||
| 69 | .divider = 0x687888ff, | ||
| 70 | }; | ||
| 71 | |||
| 72 | const trial_ansi = [16]u32{ | ||
| 73 | 0x211b1aff, 0xd46a5aff, 0x8ebf7aff, 0xd8a84eff, | ||
| 74 | 0x7aa2c8ff, 0xb28abfff, 0x62b8a9ff, 0xf3e8d0ff, | ||
| 75 | 0x665954ff, 0xef8b70ff, 0xb4d98fff, 0xf0c878ff, | ||
| 76 | 0xa8cbe8ff, 0xd1a9d1ff, 0x8cddd0ff, 0xfff4dfff, | ||
| 77 | }; | ||
| 78 | |||
| 79 | /// Hardcoded cohesive appearance trial for the native launch in this slice. | ||
| 80 | pub const trial: Theme = .{ | ||
| 81 | .terminal_fg = 0xf3e8d0ff, | ||
| 82 | .terminal_bg = 0x211b1aff, | ||
| 83 | .cursor = 0xd8a84eff, | ||
| 84 | .palette = generatedPalette(trial_ansi), | ||
| 85 | .chrome_focus_fg = 0xfff4dfff, | ||
| 86 | .chrome_focus_bg = 0x2f6f70ff, | ||
| 87 | .chrome_unfocused_fg = 0xd7c2aaff, | ||
| 88 | .chrome_unfocused_bg = 0x342824ff, | ||
| 89 | .bell_header_fg = 0xfff4dfff, | ||
| 90 | .bell_header_bg = 0x9a5c24ff, | ||
| 91 | .modal_fg = 0xf3e8d0ff, | ||
| 92 | .modal_bg = 0x2b211fff, | ||
| 93 | .modal_selected_fg = 0xfff4dfff, | ||
| 94 | .modal_selected_bg = 0x496f68ff, | ||
| 95 | .divider = 0x76594bff, | ||
| 96 | }; | ||
| 97 | |||
| 98 | test "legacy and trial retain complete generated indexed palettes" { | ||
| 99 | try std.testing.expectEqual(@as(u32, 0xcc0000ff), legacy.palette[1]); | ||
| 100 | try std.testing.expectEqual(@as(u32, 0x00005fff), legacy.palette[17]); | ||
| 101 | try std.testing.expectEqual(@as(u32, 0x080808ff), legacy.palette[232]); | ||
| 102 | 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]); | ||
| 104 | } | ||
test/native.sh
| Old | New | ||
|---|---|---|---|
| @@ -98,9 +98,10 @@ printf '%s\n' "text:head -c 4096 /dev/zero | tr '\000' '\177'" >&8 | |||
| 98 | printf 'key:enter\n' >&8 | 98 | printf 'key:enter\n' >&8 |
| 99 | 99 | ||
| 100 | # Read back real pixels after asking the shell to draw coloured text. The | 100 | # Read back real pixels after asking the shell to draw coloured text. The |
| 101 | # shell's command echo cannot satisfy the red-pixel assertion. | 101 | # Explicit RGB makes this a glyph-rendering check independent of the theme; |
| 102 | # the shell's command echo cannot satisfy the red-pixel assertion. | ||
| 102 | CAPTURE="$OUT.native.ppm" | 103 | CAPTURE="$OUT.native.ppm" |
| 103 | printf '%s\n' "text:printf '\033[2J\033[H\033[31mNATIVE-%s\177\033[0m\n' RENDER" >&8 | 104 | printf '%s\n' "text:printf '\033[2J\033[H\033[38;2;255;0;0mNATIVE-%s\177\033[0m\n' RENDER" >&8 |
| 104 | printf 'key:enter\n' >&8 | 105 | printf 'key:enter\n' >&8 |
| 105 | wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon" | 106 | wait_grid "$SOCK" "NATIVE-RENDER" "render marker reached the daemon" |
| 106 | printf 'capture:%s\n' "$CAPTURE" >&8 | 107 | printf 'capture:%s\n' "$CAPTURE" >&8 |
test/native_picker.py
| Old | New | ||
|---|---|---|---|
| @@ -162,7 +162,7 @@ def workflow(rig): | |||
| 162 | state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and | 162 | state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and |
| 163 | all(p["phase"] == "attached" for p in s["panes"])) | 163 | all(p["phase"] == "attached" for p in s["panes"])) |
| 164 | require(state["panes"][0]["outer"] == untouched, "nested split rebalanced unrelated pane") | 164 | require(state["panes"][0]["outer"] == untouched, "nested split rebalanced unrelated pane") |
| 165 | rig.shell("printf '\\033[2J\\033[H\\033[31mPICKER-%s\\033[0m\\n' EXISTING") | 165 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mPICKER-%s\\033[0m\\n' EXISTING") |
| 166 | rig.wait_marker(first, "0", "PICKER-EXISTING") | 166 | rig.wait_marker(first, "0", "PICKER-EXISTING") |
| 167 | rig.wait_state(lambda s: "PICKER-EXISTING" in s["panes"][2]["painted_text"]) | 167 | rig.wait_state(lambda s: "PICKER-EXISTING" in s["panes"][2]["painted_text"]) |
| 168 | pixels = rig.pixels() | 168 | pixels = rig.pixels() |
test/native_theme.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,163 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Assert the hardcoded appearance through real terminal output and painted pixels.""" | ||
| 3 | import shlex | ||
| 4 | import sys | ||
| 5 | |||
| 6 | sys.dont_write_bytecode = True | ||
| 7 | from native_resize import ResizeRig, build_nested, by_id, pty_sizes | ||
| 8 | from native_tiling import eventually, require | ||
| 9 | |||
| 10 | |||
| 11 | # These are acceptance values, deliberately independent of the painter source. | ||
| 12 | BG = '211b1a' | ||
| 13 | FG = 'f3e8d0' | ||
| 14 | FOCUS = '2f6f70' | ||
| 15 | INACTIVE = '342824' | ||
| 16 | DIVIDER = '76594b' | ||
| 17 | MODAL = '2b211f' | ||
| 18 | SELECTED = '496f68' | ||
| 19 | BELL = '9a5c24' | ||
| 20 | CURSOR = 'd8a84e' | ||
| 21 | ANSI = ('211b1a', 'd46a5a', '8ebf7a', 'd8a84e', | ||
| 22 | '7aa2c8', 'b28abf', '62b8a9', 'f3e8d0', | ||
| 23 | '665954', 'ef8b70', 'b4d98f', 'f0c878', | ||
| 24 | 'a8cbe8', 'd1a9d1', '8cddd0', 'fff4df') | ||
| 25 | |||
| 26 | |||
| 27 | def pixel(capture, x, y): | ||
| 28 | width, height, data = capture | ||
| 29 | x, y = int(x), int(y) | ||
| 30 | require(0 <= x < width and 0 <= y < height, 'sample outside framebuffer') | ||
| 31 | offset = (y * width + x) * 3 | ||
| 32 | return data[offset:offset + 3].hex() | ||
| 33 | |||
| 34 | |||
| 35 | def check_samples(rig, samples, label): | ||
| 36 | last = [] | ||
| 37 | def painted(): | ||
| 38 | capture = rig.last_pixels() | ||
| 39 | last[:] = [(name, expected, pixel(capture, x, y)) | ||
| 40 | for name, x, y, expected in samples | ||
| 41 | if pixel(capture, x, y) != expected] | ||
| 42 | return not last | ||
| 43 | try: | ||
| 44 | eventually(painted, label) | ||
| 45 | except AssertionError: | ||
| 46 | raise AssertionError(f'{label}: {last}') from None | ||
| 47 | |||
| 48 | |||
| 49 | def edge_sample(name, rect, expected): | ||
| 50 | return name, rect['x'] + rect['w'] - 2, rect['y'] + 2, expected | ||
| 51 | |||
| 52 | |||
| 53 | def paint_specimen(rig, pane_id, sock, session): | ||
| 54 | rig.focus(pane_id) | ||
| 55 | state = rig.state() | ||
| 56 | pane = by_id(state)[pane_id] | ||
| 57 | require(pane['cols'] >= 32 and pane['rows'] >= 12, 'theme fixture too small') | ||
| 58 | marker = 'THEME-READY-' + session | ||
| 59 | text = '\033[0m\033[2J\033[HWarm native appearance' | ||
| 60 | for index in range(16): | ||
| 61 | row, col = 3 + index // 8, 1 + 4 * (index % 8) | ||
| 62 | text += f'\033[{row};{col}H\033[48;5;{index}m \033[0m' | ||
| 63 | text += ('\033[6;1H\033[48;5;17m \033[0m' | ||
| 64 | '\033[6;5H\033[48;5;232m \033[0m' | ||
| 65 | '\033[6;9H\033[48;5;255m \033[0m' | ||
| 66 | '\033[7;1H\033[7m \033[0m' | ||
| 67 | '\033[8;1H\033[48;2;18;52;86m \033[0m' | ||
| 68 | '\033[10;1H' + marker + '\033[12;2H') | ||
| 69 | # Shell printf consumes the escaped bytes; PS1 remains blank at the fixed cursor. | ||
| 70 | rig.shell("export PS1=''; printf '%b' " + shlex.quote(text.replace('\033', '\\033'))) | ||
| 71 | rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text']) | ||
| 72 | |||
| 73 | |||
| 74 | def terminal_samples(state, pane): | ||
| 75 | content, cw, ch = pane['content'], state['cell_w'], state['cell_h'] | ||
| 76 | def cell(label, col, row, colour): | ||
| 77 | return (f"pane {pane['id']} {label}", content['x'] + (col + .5) * cw, | ||
| 78 | content['y'] + (row + .5) * ch, colour) | ||
| 79 | samples = [edge_sample('blank content', content, BG)] | ||
| 80 | for index, colour in enumerate(ANSI): | ||
| 81 | samples.append(cell(f'palette {index}', 4 * (index % 8), 2 + index // 8, colour)) | ||
| 82 | for col, colour in [(0, '00005f'), (4, '080808'), (8, 'eeeeee')]: | ||
| 83 | samples.append(cell('extended palette', col, 5, colour)) | ||
| 84 | samples += [cell('inverse defaults', 0, 6, FG), cell('explicit RGB', 0, 7, '123456')] | ||
| 85 | if pane['id'] == state['focus']: | ||
| 86 | samples.append(cell('cursor', 1, 11, CURSOR)) | ||
| 87 | return samples | ||
| 88 | |||
| 89 | |||
| 90 | def modal_samples(rig, kind): | ||
| 91 | state = rig.wait_state(lambda s: s.get(kind)) | ||
| 92 | modal = state[kind] | ||
| 93 | samples = [edge_sample(kind + ' background', modal['rect'], MODAL)] | ||
| 94 | selected = modal.get('selected', 0) | ||
| 95 | # Recovery hooks have no selected index; the initial action is Cancel/Retry. | ||
| 96 | for index, row in enumerate(modal['rows']): | ||
| 97 | if row['rect']['h']: | ||
| 98 | samples.append(edge_sample(f'{kind} row {index}', row['rect'], | ||
| 99 | SELECTED if index == selected else MODAL)) | ||
| 100 | check_samples(rig, samples, kind + ' uses themed rows') | ||
| 101 | |||
| 102 | |||
| 103 | def main(): | ||
| 104 | require(len(sys.argv) == 3, 'usage: native_theme.py MUX MUXG') | ||
| 105 | rig = ResizeRig(*sys.argv[1:]) | ||
| 106 | try: | ||
| 107 | identities, sessions = build_nested(rig) | ||
| 108 | for pane_id, (sock, session) in sessions.items(): | ||
| 109 | paint_specimen(rig, pane_id, sock, session) | ||
| 110 | for focused in sessions: | ||
| 111 | rig.focus(focused) | ||
| 112 | state = rig.state() | ||
| 113 | samples = [] | ||
| 114 | for pane in state['panes']: | ||
| 115 | samples += terminal_samples(state, pane) | ||
| 116 | samples.append(edge_sample('header', pane['header'], | ||
| 117 | FOCUS if pane['id'] == focused else INACTIVE)) | ||
| 118 | for rail in state['dividers']: | ||
| 119 | rect = rail['rect'] | ||
| 120 | samples.append(('divider', rect['x'], rect['y'], DIVIDER)) | ||
| 121 | check_samples(rig, samples, 'terminal, headers, cursor and dividers') | ||
| 122 | rig.ok('three panes: all ANSI colours, extended palette, inverse, RGB, focus and cursor') | ||
| 123 | |||
| 124 | rig.chord('enter') | ||
| 125 | rig.picker('hosts') | ||
| 126 | modal_samples(rig, 'picker') | ||
| 127 | rig.key('down') | ||
| 128 | modal_samples(rig, 'picker') | ||
| 129 | rig.key('escape') | ||
| 130 | rig.key('escape') | ||
| 131 | rig.chord('p') | ||
| 132 | modal_samples(rig, 'recovery') | ||
| 133 | rig.key('escape') | ||
| 134 | rig.ok('host picker and recovery menu use themed backgrounds and selection') | ||
| 135 | |||
| 136 | state = rig.state() | ||
| 137 | header = by_id(state)[state['focus']]['header'] | ||
| 138 | rig.shell("printf '\\007'") | ||
| 139 | check_samples(rig, [edge_sample('bell', header, BELL)], 'bell highlight') | ||
| 140 | check_samples(rig, [edge_sample('focus after bell', header, FOCUS)], 'bell expires') | ||
| 141 | pty_sizes(rig, sessions, identities) | ||
| 142 | rig.ok('bell theme expires back to focus; every PTY keeps its dimensions') | ||
| 143 | for remaining in (2, 1, 0): | ||
| 144 | rig.chord('d') | ||
| 145 | rig.wait_state(lambda s: len(s['panes']) == remaining) | ||
| 146 | state = rig.wait_state(lambda s: not s['picker'] and not s['recovery']) | ||
| 147 | # The empty menu is centred and its Add pane row is the second of four. | ||
| 148 | x, y, ch = state['width'] / 2, state['height'] / 2, state['cell_h'] | ||
| 149 | check_samples(rig, [('empty background', x, y - 1.5 * ch + 2, MODAL), | ||
| 150 | ('Add pane highlight', x, y - .5 * ch + 2, SELECTED), | ||
| 151 | ('empty surroundings', 1, 1, DIVIDER)], 'empty workspace theme') | ||
| 152 | for sock, session in sessions.values(): | ||
| 153 | require('THEME-READY-' + session in rig.dump(sock, session), | ||
| 154 | 'detaching removed the daemon session') | ||
| 155 | rig.ok('empty workspace and Add pane are themed; detached sessions survive') | ||
| 156 | rig.quit() | ||
| 157 | print('PASS: native appearance;', rig.root, flush=True) | ||
| 158 | finally: | ||
| 159 | rig.close() | ||
| 160 | |||
| 161 | |||
| 162 | if __name__ == '__main__': | ||
| 163 | main() | ||
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -335,7 +335,7 @@ def normal_scenario(rig, axis): | |||
| 335 | rig.wait_marker(second, "right", "RIGHT-MARK") | 335 | rig.wait_marker(second, "right", "RIGHT-MARK") |
| 336 | require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane") | 336 | require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane") |
| 337 | rig.chord("h" if axis == "v" else "k") | 337 | rig.chord("h" if axis == "v" else "k") |
| 338 | rig.shell("printf '\\033[2J\\033[H\\033[31mLEFT-%s\\033[0m\\n' MARK") | 338 | rig.shell("printf '\\033[2J\\033[H\\033[38;2;255;0;0mLEFT-%s\\033[0m\\n' MARK") |
| 339 | rig.wait_marker(first, "left", "LEFT-MARK") | 339 | rig.wait_marker(first, "left", "LEFT-MARK") |
| 340 | require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane") | 340 | require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane") |
| 341 | state = rig.state() | 341 | state = rig.state() |