a73x

58e66682

feat: centralize native colours with a warm theme trial

a73x   2026-09-06 05:16

Commit message
feat: centralize native colours with a warm theme trial

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()