a73x

c5e30b30

feat: add independent native terminal panes

a73x   2026-09-05 06:42

Commit message
feat: add independent native terminal panes

CLAUDE.md
Old New
@@ -17,7 +17,7 @@ make build test e2e # Makefile already points at it
17 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate 17 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate
18 make ci # check + e2e + agent + throughput — the delivery gate 18 make ci # check + e2e + agent + throughput — the delivery gate
19 make agent soak bench throughput 19 make agent soak bench throughput
20 make native native-e2e # opt-in muxg viewer; system SDL3/freetype/fontconfig/HarfBuzz + GL headers, never in ci 20 make native native-e2e # opt-in muxg; SDL3/freetype/fontconfig/HarfBuzz + GL headers; e2e also needs python3
21 make vm # real user journeys against the mux-e2e VM (test/vm.sh) 21 make vm # real user journeys against the mux-e2e VM (test/vm.sh)
22 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh). 22 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh).
23 # Both take their boxes BY NAME and have no default 23 # Both take their boxes BY NAME and have no default
@@ -79,7 +79,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
79 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | 79 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
80 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 80 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
81 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 81 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
82 | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | 82 | `src/gui/` | `native`(`native.zig`) — `workspace` `runtime` `font` `atlas` `quads` `gl` `frame` `bench` |
83 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 83 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
84 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 84 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
85 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 85 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
@@ -117,7 +117,7 @@ the OS for a terminal is platform code, and rule 4 forbids a client module
117 from doing it. 117 from doing it.
118 118
119 Rules 5, 6 and 7 also cover `src/gui/`. Rule 8 keeps multi-session policy 119 Rules 5, 6 and 7 also cover `src/gui/`. Rule 8 keeps multi-session policy
120 out of that folder. Rule 9 confines SDL to `src/gui/frame.zig`; the separate 120 independent of terminal wall policy and persistence. Rule 9 confines SDL to `src/gui/frame.zig`; the separate
121 entry `src/cli/muxg.zig` may also use it. Everything else in the painter is 121 entry `src/cli/muxg.zig` may also use it. Everything else in the painter is
122 unit-tested without opening a window. 122 unit-tested without opening a window.
123 123
@@ -133,7 +133,7 @@ own. Test fixtures in `test/`:
133 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in), 133 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in),
134 `rawmode`, `delaypipe`, `render` — those stay separate binaries. 134 `rawmode`, `delaypipe`, `render` — those stay separate binaries.
135 The opt-in second product binary is `muxg`, a dynamically linked window on 135 The opt-in second product binary is `muxg`, a dynamically linked window on
136 one session; it is built only by the native steps and is never installed by 136 native session panes; it is built only by the native steps and is never installed by
137 `make install` or included in `ci`. 137 `make install` or included in `ci`.
138 138
139 ## Invariants — do not break, they are load-bearing 139 ## Invariants — do not break, they are load-bearing
README.md
Old New
@@ -19,24 +19,25 @@ make test && make e2e # verify
19 make install # the one binary to ~/.local/bin (override BINDIR) 19 make install # the one binary to ~/.local/bin (override BINDIR)
20 ``` 20 ```
21 21
22 The native one-session viewer is an opt-in, dynamically linked second 22 The native client is an opt-in, dynamically linked second
23 binary. It needs SDL3, freetype2, fontconfig, HarfBuzz and OpenGL development 23 binary. It needs SDL3, freetype2, fontconfig, HarfBuzz and OpenGL development
24 packages; OpenGL functions are loaded through SDL, with no direct libGL 24 packages; OpenGL functions are loaded through SDL, with no direct libGL
25 link. It is outside the default build and CI gates: 25 link. It is outside the default build and CI gates:
26 26
27 ```sh 27 ```sh
28 make native # build muxg and run its no-window unit tests 28 make native # build muxg and run its no-window unit tests
29 make native-e2e # ReleaseSafe real-daemon, input, render, flood, resize and detach leg 29 make native-e2e # ReleaseSafe single- and two-pane integration checks; needs python3
30 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] 30 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
31 ``` 31 ```
32 32
33 `muxg` displays and types into one daemon session. With no target it uses 33 `muxg` displays daemon sessions in native terminal panes. With no target it uses
34 the local socket, whose daemon must already be running (`mux d start -d`); 34 the local socket; start its daemon with `mux d start -d`. It never starts a local
35 it never starts a local daemon itself. A remote `HOST` still uses mux's SSH 35 daemon itself, and an unavailable target is shown in its pane. A remote `HOST` uses mux's SSH
36 handoff, including that handoff's own remote-start behavior. `--via` uses a 36 handoff, including that handoff's own remote-start behavior. `--via` uses a
37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`. 37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
38 Closing the window detaches; the session stays on its daemon. When the shell 38 Closing the window detaches all panes; their sessions stay on their daemons. When
39 exits, the window closes with its exit code. `--font-px` sets the font size 39 a shell exits, its pane shows the exit status and the window remains open.
40 `--font-px` sets the font size
40 at 100% display scale (default 16); glyphs are rasterized at the monitor's 41 at 100% display scale (default 16); glyphs are rasterized at the monitor's
41 actual scale and refreshed when the window moves between displays. Linux 42 actual scale and refreshed when the window moves between displays. Linux
42 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER` 43 prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER`
@@ -46,6 +47,28 @@ The end-to-end leg checks the real build mode, reads rendered pixels back
46 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent 47 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
47 output; pump apply time is reported separately. 48 output; pump apply time is reported separately.
48 49
50 The first native tiling sprint supports one staged second target:
51
52 ```sh
53 ./zig-out/bin/muxg alpha --session work --next-target beta --next-session logs
54 # Local second daemon: --next-target '--sock /tmp/second.sock' --next-session logs
55 ```
56
57 Press **Ctrl+Shift+Space**, then **v** for side by side or **b** for above/below.
58 This shows the intended split. Press the prefix again, then **Enter**, to insert
59 the staged target. Cancellation creates no second pane or session. The split stays
60 on the pane where it was armed, even if focus moves before insertion.
61
62 Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses
63 it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its
64 normal terminal encoding through once. Window resizing updates every pane's PTY.
65 An exited or unavailable pane leaves other panes usable.
66
67 Host/session pickers, divider resizing, and saved native layouts follow in later
68 sprints. Tabs are represented in the ownership model but have no UI yet. Native
69 layout choices are independent of terminal mux; both clients use the same daemon
70 sessions.
71
49 Binaries land in `zig-out/bin/`. For remote machines, build a static binary 72 Binaries land in `zig-out/bin/`. For remote machines, build a static binary
50 that runs on any x86_64 Linux: 73 that runs on any x86_64 Linux:
51 74
build.zig
Old New
@@ -476,7 +476,7 @@ const source_bans = [_]SourceBan{
476 .rule = "8", 476 .rule = "8",
477 .folders = &.{"src/gui"}, 477 .folders = &.{"src/gui"},
478 .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" }, 478 .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" },
479 .why = "src/gui/ renders one session and contains no multi-session policy", 479 .why = "src/gui/ owns its workspace independently of terminal wall policy and persistence",
480 }, 480 },
481 .{ 481 .{
482 .rule = "9", 482 .rule = "9",
@@ -1161,8 +1161,12 @@ pub fn build(b: *std.Build) void {
1161 const native_e2e = b.addSystemCommand(&.{"test/native.sh"}); 1161 const native_e2e = b.addSystemCommand(&.{"test/native.sh"});
1162 native_e2e.addArtifactArg(mux_exe); 1162 native_e2e.addArtifactArg(mux_exe);
1163 native_e2e.addArtifactArg(muxg_exe); 1163 native_e2e.addArtifactArg(muxg_exe);
1164 const native_tiling = b.addSystemCommand(&.{ "python3", "test/native_tiling.py" });
1165 native_tiling.addArtifactArg(mux_exe);
1166 native_tiling.addArtifactArg(muxg_exe);
1167 native_tiling.step.dependOn(&native_e2e.step);
1164 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1168 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1165 native_e2e_step.dependOn(&native_e2e.step); 1169 native_e2e_step.dependOn(&native_tiling.step);
1166 1170
1167 const soak = b.addSystemCommand(&.{"test/soak.sh"}); 1171 const soak = b.addSystemCommand(&.{"test/soak.sh"});
1168 // The same list the e2e step passes, in the same order: soak IS that 1172 // The same list the e2e step passes, in the same order: soak IS that
docs/superpowers/plans/2026-09-05-native-tiling.md
Old New
@@ -0,0 +1,305 @@
1 # Native tiling — delegated delivery plan
2
3 Design authority: [native tiling spec](../specs/2026-09-05-native-tiling-design.md).
4 Status: Sprint 1 implemented, refactored, reviewed, and validated; ready for ergonomic trial.
5
6 ## Working agreement
7
8 Keep one sprint active. The root agent owns integration, user updates, acceptance
9 evidence, and commits. Assign one implementer and one independent adversarial
10 reviewer by default. Reuse those agents across packages; do not spawn a new agent
11 for every file or test. A second implementer is useful only for an independent
12 package with agreed interfaces and disjoint file ownership.
13
14 Give each agent the spec, the current package below, relevant files, acceptance
15 criteria, and the other agent's name. They inspect the code needed for that task;
16 no blanket skill loading or repeated whole-repository surveys. The implementer
17 owns edits. The reviewer sends concrete failures or counterexamples directly to
18 the implementer, who fixes them and returns evidence. Review changed behavior
19 and its tests, not just the implementation's own account.
20
21 A review finding records the failing scenario, consequence, and expected result.
22 Each is closed with a fix and verification, or a reason both agents accept that
23 it does not apply. If they cannot agree, root adjudicates against the spec and
24 observed behavior; do not iterate merely to manufacture agreement. Ask the user
25 only for a product decision that existing requirements do not settle.
26
27 Every sprint reserves an explicit refactoring package after functional integration
28 and before final acceptance. The implementer and reviewer inspect the combined
29 change for duplicated logic, unclear ownership, inconsistent interfaces or naming,
30 unnecessary state, obsolete paths, temporary scaffolding, and technical debt in
31 the touched code. Complete the cleanup as part of the sprint, including updating
32 tests and documentation; it is not an optional follow-up after the feature ships.
33
34 Deduplicate behavior where the responsibilities genuinely match, simplify ownership
35 and error handling, and remove superseded helpers and test-only bypasses. Keep
36 the GUI and terminal interaction policies separate. Do not introduce speculative
37 frameworks or broaden into an unrelated CLI redesign to satisfy a cleanup quota.
38 If a concrete debt item must remain, record its location, consequence, reason for
39 deferral, and the package or trigger that will address it. Root resolves disputed
40 deferrals; known correctness failures cannot be deferred past acceptance.
41
42 The reviewer reports remaining findings explicitly and reviews the refactored
43 result. Root inspects the final diff and runs the independent integration gate
44 against that result. Earlier passes do not validate subsequent refactoring: rerun
45 affected checks and the sprint acceptance scenario after cleanup. Commit a sprint
46 only after required checks pass and review findings are resolved. Record the commit, exact validation
47 commands/results, and material limits in the delivery record. Do not proceed to
48 the next sprint with a broken build or an unresolved acceptance failure.
49
50 ## Sprint 1 — two real panes
51
52 User-visible result: two terminals on independent hosts in one native window,
53 with `v`/`b` insertion, prefix + `h/j/k/l` and click focus, independent input and
54 PTY sizing. One flood, disconnect, or shell exit cannot take down the other pane.
55 Window close detaches sessions. No picker, persistence, divider dragging, or tab
56 UI is required yet. One tab exists in the model from the start.
57
58 ### 1A. Ownership and layout contract
59
60 Implementer owns a new SDL-free GUI workspace model and its focused tests. Reuse
61 `src/client/layout.zig` behind an adapter if it preserves the agreed semantics;
62 do not change terminal-client tiling policy. Root owns any module registration
63 and build rule changes once the model's location and exports are agreed.
64
65 Define stable workspace-wide pane IDs, tab IDs, one-tab ownership, per-tab focus
66 and pending direction, and leaf insertion/removal. Define a render-facing list
67 of pane IDs and rectangles, including terminal content versus headers/dividers.
68 Specify how layout units convert to physical pixels and terminal cells, how a
69 too-small window is handled without discarding panes, and how transient IDs map
70 to the existing tree's bounded leaf indices.
71
72 For a window smaller than the tree's minimum footprint, retain a valid minimum
73 layout and clip it to the window. Never send a zero PTY size or discard a leaf;
74 hit-testing is restricted to visible rectangles. Refuse a new split that cannot
75 fit the current viewport, while existing splits survive a later window shrink.
76
77 Freeze the small model/painter interface with the reviewer before 1B: lifecycle
78 ownership, rectangle units, insertion transaction, target-string lifetimes, and
79 focus access. Include failure cleanup and a rule that callbacks use pane ID plus
80 attachment generation, never array position or current focus.
81
82 Acceptance: side-by-side and above/below splits, one nested split, directional
83 neighbors, cancellation with unchanged geometry, minimum sizes, removal and stable
84 IDs, and allocation failure without a half-inserted pane. Existing GUI still builds.
85
86 ### 1B. Multiple attachments and pane rendering
87
88 Implementer owns `src/gui/frame.zig`, pane runtime integration, and any necessary
89 changes to `src/gui/quads.zig` / `src/gui/gl.zig`. Root integrates exports in
90 `src/gui/native.zig`. Depends on the reviewed 1A interface.
91
92 Use one existing `client.session_pump` per pane. Own targets for the entire pump
93 lifetime, keep wakes safe through removal/shutdown, and isolate dialing, exited,
94 failed, and reconnecting state per pane. Stop treating one pane's phase as a
95 reason to return from the whole window loop. The new pane-exit behavior also
96 applies with one pane: revise the existing shell-exit-7 native test to assert an
97 exited pane and a still-usable GUI, followed by an explicit quit. Update help/spec
98 claims about process exit propagation deliberately; do not retain two competing
99 pane lifecycle models merely to keep that old assertion.
100
101 Build the whole active tab using physical content rectangles, pane-local offsets,
102 clipping, labelled headers, and only the focused cursor. Prepare glyphs for ALL
103 visible panes before normalizing any atlas UVs: another pane growing the shared
104 atlas must not invalidate earlier instances. First copy visible cells and owned
105 text into a reusable frame snapshot under one pump lock at a time; prepare and
106 emit from those same snapshots. A second read of live grids could introduce new
107 text after glyph preparation. Never borrow row text after releasing its pump lock.
108 Bound work per pump and do not hold multiple pump mutexes together. Resize each
109 attached PTY from its own content area.
110
111 Acceptance: distinct grids produce distinct clipped pixels; shrinking a pane
112 clears its old region; later-pane glyphs that grow the atlas do not corrupt earlier
113 panes; font-scale changes update every pane. A failed or exited pane remains visible
114 while another accepts input. Closing the window releases resources and preserves
115 daemon sessions, including when a pane is still dialing.
116
117 ### 1C. Human-usable insertion and focus
118
119 Implementer owns GUI command handling and `src/cli/muxg.zig` argument changes.
120 Depends on 1B; the same implementer avoids competing edits to `frame.zig`.
121
122 Provide `--next-target SPEC --next-session NAME` for one staged second pane, using
123 the existing host-spec grammar (SSH host, `quic://...`, or the single string
124 `--sock PATH`) and session
125 name validation. Both flags are required together; reject repeated staged targets
126 in this milestone. The primary target keeps its existing CLI syntax. For example:
127 `muxg alpha --session work --next-target beta --next-session logs`.
128 For a local fixture, use `--next-target '--sock /tmp/b.sock' --next-session logs`.
129 The second target is staged until prefix + `v`/`b`, then prefix + Enter inserts it.
130 This is a usable substitute for session selection in sprint 1;
131 sprint 2 replaces target acquisition with the picker while retaining insertion.
132 The target should be selectable from real CLI arguments, not only a test FIFO.
133
134 Implement Ctrl+Shift+Space command mode, visible pending direction, `h/j/k/l`
135 and arrow focus, click focus, cancellation, and prefix passthrough. Consume both
136 key events and associated SDL text events correctly: commands must not leak bytes
137 into a terminal or suppress subsequent ordinary text. Window resize remains live.
138 Pending insertion retains the pane ID armed by `v`/`b` even if focus moves before
139 Enter; the preview stays on that pane. Removing that pane cancels its pending
140 insertion. Re-arming explicitly replaces the pending pane/direction.
141
142 Acceptance: a person can launch and insert the second explicit target in either
143 direction and type independently in both. Exercise actual prefix/key/text events,
144 mouse coordinates at high DPI, cancellation, and input after command completion.
145 Pressing the prefix twice forwards its normal terminal keymap encoding once and
146 exits command mode. Test that passthrough explicitly rather than leaving it implicit.
147 Extend test hooks only as needed to drive those same event paths.
148
149 ### 1D. Deduplication, refactoring, and debt cleanup
150
151 Depends on integrated 1A–1C. The implementer owns cleanup; the reviewer challenges
152 the resulting boundaries and any retained debt. Root coordinates changes to its
153 harness files rather than allowing concurrent edits.
154
155 Inspect the combined model, runtime, painter, CLI, and test changes. Consolidate
156 repeated rectangle/cell calculations, target ownership and cleanup, input-mode
157 transitions, and pane status handling where their semantics match. Remove obsolete
158 single-pane paths and transitional helpers. Keep one authoritative source for
159 focus, geometry, and attachment identity. Preserve the supported explicit-target
160 launch surface; remove only scaffolding that the integrated implementation replaces.
161
162 Acceptance: concrete cleanup completed and reviewed, with a concise account of
163 what was simplified and any justified remaining debt. No requirement to invent
164 an abstraction when the integrated code is already straightforward. Final tests
165 and the GUI demonstration in 1E run on the cleaned-up implementation.
166
167 ### 1E. Independent acceptance and commit
168
169 Root owns `test/native.sh` or a separate native-tiling integration leg, runs the
170 GUI demonstration, and summarizes evidence. The reviewer reviews the harness as
171 well as production changes, looking for checks that can pass with a broken GUI.
172 Harness work can be prepared alongside 1B after hook and interface agreement;
173 final acceptance runs after 1D. No other agent edits root's harness files.
174
175 - Start two isolated daemons with separate sockets and state directories. Never
176 restart or alter the user's live sessions for automated checks.
177 - Insert both orientations. Type unique markers through keyboard and click focus;
178 assert arrival in the intended daemon and absence from the other.
179 - Inspect framebuffer regions for each pane's distinct content, focus indicator,
180 clipping, and erased pixels after shrink. A capture that forces a redraw is not
181 evidence that ordinary output wakes the painter; separately check frame progress.
182 - Check BOTH reported PTY sizes against pane content geometry after window resize.
183 - While A floods bounded output, send a marker to B and require its delivery and
184 painting within a stated deadline. Then make A unavailable; B remains usable.
185 Cover A's shell exit and an initially unreachable target as distinct cases.
186 - Close the window and verify surviving daemon sessions can be attached again.
187 - Run native Wayland at the real display scale. Validate an available real remote
188 transport separately; distinguish Unix-socket, SSH, and QUIC coverage in the report.
189 If no remote fixture is available, finish and report the isolated-daemon gate and
190 runnable build, with remote acceptance explicitly outstanding. Obtain a fixture
191 before claiming SSH/QUIC or real multi-host validation; do not stall unrelated work
192 waiting on external infrastructure or silently count local sockets as remote hosts.
193
194 Run the ReleaseSafe native build/tests and relevant integration leg. Run repository
195 checks and existing-client regressions if shared modules/build boundaries changed.
196 Report measured deadlines and performance against the agreed single-window budget;
197 do not broaden/repeat testing after a pass without a new change or unresolved risk.
198
199 ## Subsequent sprints
200
201 The following packages use the same implementation → refactoring → adversarial
202 review → root acceptance loop.
203 Do not dispatch them while sprint 1 is still under integration or ergonomic review.
204
205 | Sprint / package | Owned work and dependencies | Required demo / acceptance |
206 | --- | --- | --- |
207 | 2A: picker data | Cancellable host/session discovery and creation, through existing client APIs; depends on stable pane IDs | Slow/unreachable hosts do not block another picker request; stale responses cannot insert into a replaced pane |
208 | 2B: picker UI | Host list → existing/new session list; command routing and insertion use 1C; depends on 2A | Nested splits across hosts; Esc/back creates nothing; session creation errors preserve the workspace; missing-session attach semantics explicitly checked |
209 | 2C: ergonomic gate | Root trials selection, pending direction, insertion geometry, and prefix bindings with user | Fix confusing behavior before extending interactions; focused integration and adversarial review pass |
210 | 2D: refactor and accept | Clean up picker states, discovery ownership, repeated host/session operations, and replaced staging scaffolding after the ergonomic trial | Reviewer approves cleanup/debt record; rerun picker acceptance before commit |
211 | 3A: resize model | Divider geometry, weights, minimum dimensions; depends on reviewed insertion semantics | Nested splits resize predictably; minimum dimensions preserve pane identity; pure layout checks |
212 | 3B: resize interaction | Divider dragging and keyboard resize mode; depends on 3A | Each PTY follows its pane; commands do not leak; clipped glyphs and old pixels remain correct at high DPI |
213 | 3C: refactor and accept | Consolidate rectangle conversion, hit-testing, and resize constraints across keyboard and mouse paths | Reviewed cleanup; rerun resize and high-DPI acceptance before commit |
214 | 4A: lifecycle/recovery | Distinct detach/end, retry and in-place retarget, missing/exited/offline state; depends on picker and stable identity | Detach preserves session; end respects daemon outcome; failure retains pane; restored missing sessions are not silently recreated |
215 | 4B: native persistence | Versioned tabs-aware file, atomic save/load, default workspace ownership, explicit-target temporary workspace | Close/reopen restores layout, targets, focus and sizes; offline target retained; malformed state preserved; terminal layout untouched |
216 | 4C: restore integration | Coordinate attachment restoration, cancellation, recovery UI, and shutdown; depends on 4A/4B | One unavailable host does not delay usable hosts; an unavailable host can return; no orphan pumps or accidental session termination |
217 | 4D: refactor and accept | Deduplicate live/restore attachment lifecycle, simplify save/load ownership, and remove superseded recovery paths | Reviewed cleanup/debt record; rerun lifecycle and persistence acceptance before commit |
218 | 5: final cleanup and review | Review accumulated debt across sprints, finish justified cross-package simplification, then run the complete workflow and final adversarial review | Multi-host acceptance, resource cleanup, persistence failures, high DPI and final ergonomic trial on final code; runnable build and commit |
219
220 Package 2A must include cancellation during transport opening as well as request
221 handling. Current `listSessions`/`endSession` paths open with no abort FD and start
222 reply deadlines after dialing. Add narrow cancellable API support where necessary;
223 moving those existing calls to workers alone does not bound shutdown. Test an
224 unresponsive dial, a slow reply, cancellation, and late completion after retargeting.
225
226 Resolve attach-existing versus create semantics in 2A before wiring 2B. A session
227 can vanish between listing and attachment, and the current attach path may create
228 it again. Establish an existing-only operation/capability or an explicitly refused
229 unsupported path; a preflight list alone cannot provide that guarantee. Preserve
230 legacy client behavior when extending shared wire handling and cover old-daemon
231 compatibility. Reuse this guarantee for persistence in sprint 4.
232
233 Possible later parallelism: 3A geometry versus root's resize harness, and 4A lifecycle
234 versus 4B serialization after agreeing the serializable model. Discovery and picker
235 UI, frame-loop rendering and input, and persistence plus concurrent workspace edits
236 must stay sequential until their interfaces are settled.
237
238 ## Delivery record
239
240 2026-09-05: independent planning review completed with no material findings left.
241 Resolved frame snapshot ownership, cancellation during dialing, attach/create races,
242 tiny-window behavior, command passthrough, and exact initial target syntax.
243
244 ### Sprint 1 — 2026-09-05
245
246 Packages 1A–1D are complete. Native ownership now lives in `workspace.zig` and
247 `runtime.zig`: a one-tab workspace, stable pane identities, independent pumps,
248 pixel geometry, and owned snapshots for frame construction. The staged-target CLI,
249 `v`/`b` insertion, keyboard/click focus, per-pane resize, retained exit/offline state,
250 and window detach are implemented. No terminal-client interaction policy changed.
251
252 The fixed binary pixel tree replaces the proposed adapter to the terminal tree:
253 its fixed capacity permits transactional insertion, both divider orientations,
254 and native pixel minima without changing the terminal tree's cell rails or policy.
255 The first version divides each branch equally; weights arrive with Sprint 3.
256
257 Refactoring completed before final native validation: one geometry policy, one
258 target resolver, shared glyph preparation/clipping/artifact writing, removal of
259 the single-pane process-exit path, and broadcast cancellation before pump joins.
260 Review caught and fixed held command-key repeat leaking into terminal input.
261 The two agents reached agreement on production code and the independent harness.
262
263 Validation completed:
264
265 - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache deps/zig/zig build native native-test
266 -Doptimize=ReleaseSafe --summary all`: 25 native tests passed; ReleaseSafe GUI built.
267 - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache make native-e2e`: 10 existing native
268 checkpoints and 14 new tiling checkpoints passed against the current daemon.
269 Unix/Unix and Unix/QUIC pane pairs are covered. The measured quiet-pane input
270 reached an actual painted frame in 81 ms during a neighbouring flood; window
271 p99 was 72 microseconds against the 20 ms budget. Prefix passthrough produced
272 exactly NUL followed by A through a real PTY (`od`: `00 41`).
273 - Native Wayland: 1698×2760 framebuffer at 849×1380 logical size, 200% density;
274 both daemon sizes, independent coloured pixels, and a non-origin pane click
275 converted from physical pixels to logical input coordinates passed.
276
277 The full repository gate (`make ci`) passed on the final refactored source:
278 formatting, unit tests, architecture checks, 114 end-to-end scenarios with 38
279 convergence points, 10 agent checks, and throughput limits. Validation also fixed
280 an unrelated mouse-test false positive: the literal `60` could match the runner
281 PID in the pane's socket label. The assertion now uses `app-history-row-60`;
282 terminal behavior is unchanged.
283
284 Real remote-host validation passed against `ubuntu@10.78.5.4`, using the dedicated
285 fixture SSH key and a static ReleaseSafe daemon built from the current source.
286 `PYTHONDONTWRITEBYTECODE=1 python3 /tmp/muxg-remote-fixture/validate.py ubuntu@10.78.5.4`
287 exited 0. Both local/SSH and local/direct-QUIC pairs demonstrated independent
288 daemon grids, last-painted frame text, coloured pixels confined to each pane,
289 keyboard refocus, and sessions surviving GUI close. SSH used the actual endpoint
290 fallback; QUIC used a direct `quic://` target. Remote resize/flood coverage was
291 not repeated; those cases passed in the isolated integration gate above.
292 The fixture removed its temporary daemons and remote directory and left the VM
293 running. Logs: `/tmp/muxg-tiling-real-remote.log`; local frame artifacts:
294 `/tmp/muxg-tiling-rfzrpp0b`. Independent review found no outstanding blockers.
295
296 Sprint commit: `feat: add independent native terminal panes`. The next gate is
297 the user's ergonomic trial before Sprint 2 picker work.
298
299 Retained debt for package 2A: transport opening still has inherited synchronous
300 Unix connect and DNS resolution before cancellable waits. Broadcast cancellation
301 improves multi-pane shutdown but does not make those stages interruptible. Extend
302 transport cancellation with discovery/lifecycle work and add stalled-connect/DNS
303 checks there; do not claim a universal shutdown bound from attached/missing-socket
304 tests. GUI sessions/layout persistence, picker UI, divider resizing, and tab UI
305 remain the explicitly deferred later sprints.
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -0,0 +1,230 @@
1 # Native tiling — draft design and delivery plan
2
3 2026-09-05. Status: Sprint 1 implemented and validated; ready for ergonomic trial.
4
5 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md).
6
7 ## Agreed requirements
8
9 - Sway-like directional splitting within a GUI window. Select horizontal or
10 vertical first; the layout changes only when a new pane is inserted.
11 - Insert through a host picker, followed by a session picker offering existing
12 sessions and creation of a new session.
13 - GUI layout persistence is independent of terminal mux's layout and pane choices.
14 - Both clients use the same daemons and session catalogue. Keep terminal mux's
15 interaction model separate; native-client learning may inform it later.
16 - Design pane ownership and persistence to accommodate tabs later. The initial
17 deliverable has one tab internally, without a tab bar or tab commands.
18 - Multiple hosts and pane resizing are required for the first useful release.
19 - Detaching a pane and ending its session are distinct actions.
20 - An unreachable host retains its pane and displays its state; it is recoverable.
21 - Rearranging panes is a stretch goal.
22
23 This design supersedes the single-session limit and native-pane prerequisite in
24 the 2026-09-04 native-client spec. It does not require migrating the terminal
25 wall and browser to a common controller before native tiling can ship.
26
27 ## Workspace and future tabs
28
29 Ownership is explicit from the first sprint:
30
31 ```text
32 GUI window: SDL/GL resources, shared font atlas, workspace
33 Workspace: ordered tabs, active tab ID
34 Tab: stable ID, split tree, focused pane ID, pending split
35 Pane: stable ID, target/session identity, connection and terminal grid
36 ```
37
38 The window owns rendering resources; each tab owns its layout and panes. A pane
39 belongs to exactly one tab and owns its session attachment. Pane IDs are unique
40 within the workspace and independent of array positions, so asynchronous replies
41 cannot be routed by the active tab or a reused slot. Tab and pane actions take
42 explicit IDs. Cancellation generations also distinguish replaced attachments.
43
44 Only the active tab receives terminal input and supplies rectangles for drawing.
45 Future tab switching changes visibility, not session ownership: hidden panes keep
46 their connections and last valid PTY dimensions, without sending zero-sized
47 resizes. They continue applying bounded output updates without requiring window
48 repaints. Activation recalculates geometry at the current display scale and paints
49 the latest state. An exited session or failed connection belongs to its pane even
50 when its tab is inactive; it must not terminate the window or steal focus.
51
52 Closing a pane changes its tab's tree. An empty tab remains an Add pane surface.
53 Closing the window detaches every tab's panes. Future tab-close behavior will be
54 specified with the tab UI; do not make it an alias for ending remote sessions.
55 Keep global keyboard modes transient and cancel them on a future tab switch;
56 focus and pending insertion belong to their tab. Picker operations capture their
57 originating tab and pane and must never insert into whichever tab happens to be
58 active when a network reply arrives.
59
60 Implement only the ownership boundary needed for one tab now. Tab creation,
61 switching, reordering, and background-tab scheduling tests arrive with that feature;
62 they are not prerequisites for the two-pane deliverable.
63
64 ## Proposed interaction defaults
65
66 The defaults below are proposals to trial, rather than additional requirements.
67
68 Horizontal means side by side, with the new pane to the right. Vertical means
69 above/below, with the new pane below. Show those words and a small preview so
70 the orientation is unambiguous.
71
72 Direction selection arms the focused pane for one insertion and changes no
73 geometry. Opening the picker captures that pane and direction. Host selection
74 opens its sessions; New session requests a name and creates it explicitly.
75 Only committing a session selection inserts the pane and focuses it. Cancelling
76 either level creates no pane or session. Esc in sessions returns to hosts;
77 Esc in hosts dismisses the picker. Keep the pending direction visible until
78 insertion or explicit cancellation. Start with side-by-side as the default.
79 The pending split remains bound to its original pane if focus moves; its preview
80 stays there. Re-arming replaces that choice, and removing the pane cancels it.
81
82 Initially, split the armed leaf into two equally sized children. Other panes
83 retain their allocated space. Repeated insertion semantics can be adjusted after
84 the first ergonomic trial; do not silently rebalance unrelated branches.
85
86 Keyboard prefix for the ergonomic trial: **Ctrl+Shift+Space**. It avoids Sway's usual Super
87 bindings and the existing terminal mux prefix, including mux nested in a pane.
88 The prefix opens a small command hint strip; its next key is consumed by the GUI.
89
90 | After prefix | Action |
91 | --- | --- |
92 | `v` | Arm side-by-side insertion (vertical divider) |
93 | `b` | Arm above/below insertion (new pane below) |
94 | Enter | Open host picker for insertion |
95 | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right |
96 | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits |
97 | `d` | Detach and remove the focused pane |
98 | `x` | End the focused session through the daemon's existing end workflow |
99 | `p` | Open recovery actions for the focused pane |
100 | Esc | Cancel the command and pending split |
101
102 The split and movement bindings above were agreed on 2026-09-05: reserve
103 `h/j/k/l` for directional movement, with `v` and `b` selecting split direction.
104
105 Pressing the prefix twice forwards its normal terminal keymap encoding once and
106 exits command mode. Ordinary input
107 goes only to the focused terminal; picker and resize-mode input never leaks to it.
108 Mouse click focuses a pane, and dragging a divider resizes it. Terminal mouse
109 reporting remains a separate feature. Pane headers identify host, session, focus,
110 and connection state; both detach and end actions must be clearly labelled.
111
112 ## Lifecycle and persistence
113
114 Closing the OS window saves the workspace and detaches all panes. It does not
115 end their sessions. Explicit pane detach removes that leaf from the saved layout;
116 its sibling fills the freed area. Closing the last pane leaves an empty tab
117 with an Add pane action. An ordinary shell exit leaves an exited pane with recovery
118 actions; it must not terminate the entire GUI or remove other hosts' panes. This
119 also applies to a one-pane GUI, superseding the viewer's shell-exit-code propagation.
120
121 End session uses the existing daemon request and its response, including its
122 existing handling of other attached clients. A failure to end keeps the pane and
123 shows the reason. Transport cancellation/GUI shutdown is not session termination:
124 the current session pump's `quit` and `detach` both stop the attachment.
125
126 Use a separate, versioned GUI state file, proposed at
127 `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered
128 `tabs` collection and `active_tab_id`, even while only one tab is exposed. Persist
129 each tab's stable ID, split tree, relative weights, panes, and focus. Pane records
130 hold stable IDs, explicit host/transport references, and resolved session names.
131 Pending commands and picker state are transient. Do not write terminal mux's
132 layout file. Save atomically after
133 committed changes; preserve unreadable state rather than overwriting it with an
134 empty workspace. Save divider changes when a drag finishes. A saved host reference
135 must survive removal from the shared host catalogue.
136
137 No-argument `muxg` restores the GUI workspace; first launch presents the picker.
138 An explicit CLI target opens a temporary workspace without replacing the default
139 saved workspace. For this milestone, allow one writer for the default workspace;
140 a second no-argument launch should report that it is already open. Multiple named
141 workspaces and multiple persistent windows are deferred.
142
143 Unreachable panes retain their identity, position, and last available content,
144 with a clear offline indication. Keep other panes usable. Offer Retry and Choose
145 session in place, plus Detach. Use bounded background reconnect attempts. Missing
146 or exited sessions remain visible; restoration must not silently replace a missing
147 session with a newly created shell. Check the existing attach/create semantics
148 before implementing this guarantee and add a narrow protocol capability if needed.
149
150 Confirmed: GUI and terminal clients can deliberately select the same daemon
151 session from a shared catalogue, while their layouts and automatic pane choices
152 remain independent. Sharing a session retains the daemon's existing resize and
153 attachment semantics; GUI layout isolation does not create a separate PTY.
154
155 ## Implementation boundaries
156
157 Reuse `client.session_pump` per pane, `client.listSessions`, session creation and
158 `client.endSession`, plus existing target resolution and host catalogue support.
159 Discovery and lifecycle requests run off the SDL thread with cancellation and
160 generation checks so stale picker/retry results cannot mutate a replacement pane.
161 One unreachable or flooding host must not block input or drawing on another.
162
163 Keep the GUI workspace controller independent of SDL and terminal wall policy.
164 Use the existing `client.layout.Tree` geometry where it fits: it already supports
165 split-right, split-below, weights, removal, and directional neighbors. Its current
166 cell-based rails and placement need evaluation for horizontal dividers and pixel
167 hit-testing; use a narrow adapter or extension with existing-client regressions.
168 Do not import the terminal wall picker or inherit its automatic pane selection.
169
170 The controller owns per-tab focus; the SDL frame loop routes events to the active
171 tab and computes physical pixel rectangles. Render that tab's panes with the
172 existing shared font/atlas, clip content to each pane,
173 draw only the focused cursor, and resize each PTY to its own content rectangle.
174 Retain whole-frame clearing initially. Apply display-scale changes consistently
175 to every pane, header, divider, and picker. Enforce minimum sizes during splits
176 and divider drags; shrinking the OS window must not discard saved pane identity.
177 If the existing layout cannot fit a tiny window, retain its minimum footprint and
178 clip it to the viewport; never send zero PTY dimensions. Refuse new splits that
179 cannot fit. For each frame, copy visible cells and their text under individual
180 pump locks, then prepare all glyphs and generate all UVs from that frozen data.
181
182 Revise build rule 8 and architecture comments to permit this explicit model while
183 retaining the SDL boundary and separation from the terminal wall. No broad wall
184 policy extraction is a prerequisite.
185
186 ## Small functional sprints
187
188 1. **Two real panes.** Introduce workspace/tab/pane ownership with one tab and a
189 pure split controller. Trial `v`/`b` insertion using explicit targets, focus via
190 prefix + `h/j/k/l` or mouse click, and route input/resize independently.
191 Demo two isolated daemons simultaneously, including one flooding and one idle.
192 Retain the current direct-target smoke test through a temporary workspace.
193 2. **Insertion workflow.** Connect pending direction to host and session pickers,
194 explicit creation, and expanded keyboard hints. Demo nested splits across
195 hosts; cancelling creates nothing; failed discovery keeps existing panes usable.
196 Trial the direction semantics and shortcuts here before expanding them.
197 3. **Resizing.** Add divider dragging and keyboard resize mode with minimum sizes.
198 Demo nested pane resizing, correct per-session PTY sizes, clipped glyphs, and
199 a high-DPI scale change. Assert old pixels disappear after shrinking regions.
200 4. **Lifecycle and restore.** Add separate native persistence, detach/end actions,
201 exited/offline pane recovery, and bounded shutdown. Demo close/reopen preserving
202 multiple hosts, an unavailable host returning, a missing session remaining
203 recoverable, and unchanged terminal mux layout state.
204 5. **Release review.** Exercise the full multi-host workflow, keyboard ergonomics,
205 failure isolation, default-workspace ownership, malformed saved state, and
206 high-DPI rendering. Review and commit the completed milestone.
207
208 Each sprint must leave runnable code with a concrete GUI demo. Delegate bounded
209 implementation and a separate adversarial review; they resolve findings directly
210 before completion. Reserve explicit work at the end of every sprint to deduplicate,
211 refactor, and clean up technical debt in the integrated code. Review ownership,
212 interfaces, repeated logic, and temporary scaffolding; complete cleanup before
213 final validation and commit. Record any justified remaining debt and its follow-up
214 trigger. Keep native and terminal interaction policy separate while simplifying
215 shared mechanics where appropriate. Run acceptance on the refactored result.
216 Use focused state/lifecycle tests and actual framebuffer and
217 daemon-size checks. Run broader repository checks when shared code changes.
218 Do not add ceremony or broad test runs without a specific integration risk.
219
220 First-sprint acceptance includes exact layout and focused-input tests, distinct
221 typed markers checked in each daemon grid and framebuffer, clipping and erasure
222 after resize, and each daemon reporting its own pane's PTY dimensions. Flood or
223 disconnect one daemon while the other continues accepting input and painting;
224 closing the GUI leaves sessions alive. Check native Wayland at the display's real
225 scale. Isolated local daemons establish connection independence; a separate real
226 remote-host check is needed to claim SSH/QUIC end-to-end validation, with any
227 untested transport explicitly reported. Review and commit the runnable sprint.
228
229 Rearranging panes, drag-and-drop movement, tab UI, container focus/reparenting,
230 multiple persistent windows, and terminal-client tiling redesign are later work.
src/cli/muxg.zig
Old New
@@ -1,11 +1,4 @@
1 //! `muxg`: the native client's entry. Parses ONE target the way `mux` 1 //! Native workspace entry; one primary attachment and one optional staged target.
2 //! does (HOST, --sock PATH, --via CMD, quic://HOST[:PORT]) plus --session
3 //! and --font-px, resolves it to a client.Target, and hands it to the
4 //! painter. A session viewer: no wall, no hosts file, no layout.
5 //!
6 //! No daemon is started here. The self-exec rule (CLAUDE.md) says an
7 //! auto-start may only run the image already running, and this image is
8 //! not the daemon's. A silent socket is a refusal with the command to run.
9 const std = @import("std"); 2 const std = @import("std");
10 const native = @import("native"); 3 const native = @import("native");
11 const client = @import("client"); 4 const client = @import("client");
@@ -25,6 +18,8 @@ const usage =
25 \\ --via a command whose stdio is the daemon 18 \\ --via a command whose stdio is the daemon
26 \\ --key the QUIC key file (or MUX_KEY_FILE) 19 \\ --key the QUIC key file (or MUX_KEY_FILE)
27 \\ --font-px font pixels at 100% display scale (default 16) 20 \\ --font-px font pixels at 100% display scale (default 16)
21 \\ --next-target stage one target specification for prefix v/b then prefix Enter
22 \\ --next-session session name for the staged target (required with --next-target)
28 \\ --help --version 23 \\ --help --version
29 \\ 24 \\
30 ; 25 ;
@@ -35,9 +30,27 @@ const Arguments = struct {
35 key: ?[]const u8 = null, 30 key: ?[]const u8 = null,
36 session: ?proto.SessionName = null, 31 session: ?proto.SessionName = null,
37 font_px: u16 = 16, 32 font_px: u16 = 16,
33 _next_target: ?[]const u8 = null,
34 _next_session: ?[]const u8 = null,
35 _next_targets: usize = 0,
36 _next_sessions: usize = 0,
38 _target: ?[]const u8 = null, 37 _target: ?[]const u8 = null,
39 _targets: usize = 0, 38 _targets: usize = 0,
40 39
40 pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize {
41 if (rest.len < 2) return 0;
42 if (std.mem.eql(u8, rest[0], "--next-target")) {
43 self._next_target = rest[1];
44 self._next_targets += 1;
45 return 2;
46 }
47 if (std.mem.eql(u8, rest[0], "--next-session")) {
48 self._next_session = rest[1];
49 self._next_sessions += 1;
50 return 2;
51 }
52 return 0;
53 }
41 pub fn positional(self: *Arguments, word: []const u8) bool { 54 pub fn positional(self: *Arguments, word: []const u8) bool {
42 self._target = word; 55 self._target = word;
43 self._targets += 1; 56 self._targets += 1;
@@ -72,39 +85,36 @@ pub fn main() !u8 {
72 const session = if (o.session) |n| n.name else ""; 85 const session = if (o.session) |n| n.name else "";
73 const key = std.posix.getenv("MUX_KEY_FILE"); 86 const key = std.posix.getenv("MUX_KEY_FILE");
74 87
75 var target: client.Target = undefined; 88 if (o._next_targets > 1 or o._next_sessions > 1 or (o._next_target == null) != (o._next_session == null)) {
76 if (o.via) |cmd| { 89 std.debug.print("muxg: provide --next-target and --next-session exactly once together\n", .{});
77 target = .{ .via = cmd }; 90 return 2;
78 } else if (o._target) |word| { 91 }
79 const spec = hosts.parse(word) catch |err| { 92 var next: @FieldType(native.frame.Options, "next") = null;
80 std.debug.print("muxg: bad target {s}: {s}\n", .{ word, @errorName(err) }); 93 if (o._next_target) |word| {
81 return 2; 94 const name = o._next_session.?;
82 }; 95 if (!proto.validSessionName(name)) {
83 target = client.Target.fromSpec(argv_alloc, spec, o.key orelse key, client.quic_idle_ms_default, false) catch |err| switch (err) { 96 std.debug.print("muxg: invalid --next-session\n", .{});
84 error.MissingKey => {
85 std.debug.print("muxg: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen`\n", .{});
86 return 2;
87 },
88 else => |e| return e,
89 };
90 if (target == .hand) target.hand.narrate = true;
91 } else {
92 const path = if (o.sock) |s| try argv_alloc.dupe(u8, s) else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1);
93 if (!sockpath.answers(path)) {
94 std.debug.print("muxg: no daemon at {s} (run: mux d start -d --sock {s})\n", .{ path, path });
95 return 2; 97 return 2;
96 } 98 }
97 return native.run(alloc, .{ 99 next = .{ .target = resolve(argv_alloc, word, o.key orelse key) catch |err| {
98 .target = .{ .sock = path }, 100 std.debug.print("muxg: bad staged target: {s}\n", .{@errorName(err)});
99 .session = session, 101 return 2;
100 .font_px = o.font_px, 102 }, .session = name };
101 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
102 });
103 } 103 }
104 const target: client.Target = if (o.via) |cmd| .{ .via = cmd } else if (o._target) |word| resolve(argv_alloc, word, o.key orelse key) catch |err| {
105 std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)});
106 return 2;
107 } else .{ .sock = if (o.sock) |path| path else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) };
104 return native.run(alloc, .{ 108 return native.run(alloc, .{
105 .target = target, 109 .target = target,
110 .next = next,
106 .session = session, 111 .session = session,
107 .font_px = o.font_px, 112 .font_px = o.font_px,
108 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 113 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
109 }); 114 });
110 } 115 }
116 fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target {
117 var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false);
118 if (target == .hand) target.hand.narrate = true;
119 return target;
120 }
src/gui/frame.zig
Old New
@@ -1,18 +1,10 @@
1 //! The window thread: SDL owns the window and the GL context; this file 1 //! SDL window, workspace input, and whole-frame painting of owned pane snapshots.
2 //! owns the loop. It waits on SDL's event queue; a wake from the pump or a
3 //! resize locks the replica, rebuilds every instance from the whole grid,
4 //! unlocks, uploads, draws and swaps. Keys become keymap events, text
5 //! becomes input bytes, and both go to the pump's mailbox. The ONE file
6 //! under src/gui/ that names SDL (folder rule 9).
7 //!
8 //! Whole-grid rebuild every frame is deliberate for v1: a large window is
9 //! on the order of ten thousand cells, and the timing table is what will
10 //! say whether dirty rows ever matter.
11 const std = @import("std"); 2 const std = @import("std");
12 const client = @import("client"); 3 const client = @import("client");
13 const term = @import("term"); 4 const term = @import("term");
14 const keymap = client.keymap; 5 const keymap = client.keymap;
15 const session_pump = client.session_pump; 6 const model = @import("workspace.zig");
7 const runtime = @import("runtime.zig");
16 const font = @import("font.zig"); 8 const font = @import("font.zig");
17 const atlas = @import("atlas.zig"); 9 const atlas = @import("atlas.zig");
18 const quads = @import("quads.zig"); 10 const quads = @import("quads.zig");
@@ -25,24 +17,16 @@ const c = @cImport({
25 17
26 pub const Options = struct { 18 pub const Options = struct {
27 target: client.Target, 19 target: client.Target,
20 next: ?struct { target: client.Target, session: []const u8 } = null,
28 session: []const u8, 21 session: []const u8,
29 /// Face pixel size at 100% display scale. 22 /// Face pixel size at 100% display scale.
30 font_px: u16 = 16, 23 font_px: u16 = 16,
31 width: u32 = 960, 24 width: u32 = 960,
32 height: u32 = 600, 25 height: u32 = 600,
33 /// The e2e leg's hook: a FIFO of `text:`/`key:`/`resize:`/`quit` lines. 26 /// Optional integration FIFO; events use the ordinary window input paths.
34 test_fifo: ?[]const u8 = null, 27 test_fifo: ?[]const u8 = null,
35 }; 28 };
36 29
37 pub const CellsOf = struct { cols: u16, rows: u16 };
38
39 pub fn cellsOf(px_w: u32, px_h: u32, cell_w: u16, cell_h: u16) CellsOf {
40 return .{
41 .cols = @intCast(@min(@max(px_w / @max(cell_w, 1), 1), term.protocol.max_cols)),
42 .rows = @intCast(@min(@max(px_h / @max(cell_h, 1), 1), std.math.maxInt(u16))),
43 };
44 }
45
46 /// SDL keycode + mods → the keymap's event, or null for a key that types 30 /// SDL keycode + mods → the keymap's event, or null for a key that types
47 /// (text input carries it) or means nothing to a session. 31 /// (text input carries it) or means nothing to a session.
48 pub fn keyEvent(key: u32, mod: u16) ?keymap.Event { 32 pub fn keyEvent(key: u32, mod: u16) ?keymap.Event {
@@ -91,7 +75,9 @@ pub fn keyEvent(key: u32, mod: u16) ?keymap.Event {
91 75
92 pub const Hook = union(enum) { 76 pub const Hook = union(enum) {
93 text: []const u8, 77 text: []const u8,
94 key: keymap.Key, 78 key: struct { code: u32, mods: u16 = 0 },
79 click: struct { x: f32, y: f32 },
80 state: []const u8,
95 resize: struct { w: u32, h: u32 }, 81 resize: struct { w: u32, h: u32 },
96 capture: []const u8, 82 capture: []const u8,
97 quit, 83 quit,
@@ -99,12 +85,19 @@ pub const Hook = union(enum) {
99 85
100 pub fn parseHook(line: []const u8) ?Hook { 86 pub fn parseHook(line: []const u8) ?Hook {
101 if (std.mem.eql(u8, line, "quit")) return .quit; 87 if (std.mem.eql(u8, line, "quit")) return .quit;
88 if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] };
89 if (std.mem.startsWith(u8, line, "click:")) {
90 const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null;
91 return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } };
92 }
102 if (std.mem.startsWith(u8, line, "capture:")) return .{ .capture = line[8..] }; 93 if (std.mem.startsWith(u8, line, "capture:")) return .{ .capture = line[8..] };
103 if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] }; 94 if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] };
104 if (std.mem.startsWith(u8, line, "key:")) { 95 if (std.mem.startsWith(u8, line, "key:")) {
105 const name = line["key:".len..]; 96 const name = line["key:".len..];
106 inline for (.{ "enter", "tab", "escape", "backspace", "up", "down", "left", "right" }) |n| { 97 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_SPACE, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
107 if (std.mem.eql(u8, name, n)) return .{ .key = @field(keymap.Key, n) }; 98 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvb", name[0]) != null) return .{ .key = .{ .code = name[0] } };
99 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
100 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } };
108 } 101 }
109 return null; 102 return null;
110 } 103 }
@@ -127,14 +120,15 @@ fn onUsr1(_: c_int) callconv(.c) void {
127 120
128 const Wake = struct { 121 const Wake = struct {
129 event_type: u32, 122 event_type: u32,
130 pending: std.atomic.Value(bool) = .init(false), 123 fn ring(ctx: ?*anyopaque, key: model.Attachment) void {
131
132 fn ring(ctx: ?*anyopaque) void {
133 const self: *Wake = @ptrCast(@alignCast(ctx.?)); 124 const self: *Wake = @ptrCast(@alignCast(ctx.?));
134 if (self.pending.swap(true, .acq_rel)) return; 125 var ev = std.mem.zeroes(c.SDL_Event);
135 var ev: c.SDL_Event = std.mem.zeroes(c.SDL_Event);
136 ev.type = self.event_type; 126 ev.type = self.event_type;
137 if (!c.SDL_PushEvent(&ev)) self.pending.store(false, .release); 127 ev.user.data1 = @ptrFromInt(key.pane);
128 ev.user.data2 = @ptrFromInt(key.generation);
129 // Runtime also polls atomic pending flags, so a full SDL queue cannot
130 // strand an attachment. The queue holds IDs, never freed Live pointers.
131 _ = c.SDL_PushEvent(&ev);
138 } 132 }
139 }; 133 };
140 134
@@ -148,6 +142,7 @@ const HookReader = struct {
148 used: usize = 0, 142 used: usize = 0,
149 text: std.ArrayListUnmanaged([:0]u8) = .empty, 143 text: std.ArrayListUnmanaged([:0]u8) = .empty,
150 capture: ?[]u8 = null, 144 capture: ?[]u8 = null,
145 state: ?[]u8 = null,
151 146
152 fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader { 147 fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader {
153 return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0) }; 148 return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0) };
@@ -158,6 +153,7 @@ const HookReader = struct {
158 for (self.text.items) |t| self.alloc.free(t); 153 for (self.text.items) |t| self.alloc.free(t);
159 self.text.deinit(self.alloc); 154 self.text.deinit(self.alloc);
160 if (self.capture) |p| self.alloc.free(p); 155 if (self.capture) |p| self.alloc.free(p);
156 if (self.state) |p| self.alloc.free(p);
161 } 157 }
162 158
163 fn releaseText(self: *HookReader, p: [*c]const u8) void { 159 fn releaseText(self: *HookReader, p: [*c]const u8) void {
@@ -200,17 +196,26 @@ const HookReader = struct {
200 .key => |k| { 196 .key => |k| {
201 ev.key.type = c.SDL_EVENT_KEY_DOWN; 197 ev.key.type = c.SDL_EVENT_KEY_DOWN;
202 ev.key.windowID = c.SDL_GetWindowID(win); 198 ev.key.windowID = c.SDL_GetWindowID(win);
203 ev.key.key = switch (k) { 199 ev.key.key = k.code;
204 .enter => c.SDLK_RETURN, 200 ev.key.mod = k.mods;
205 .tab => c.SDLK_TAB, 201 if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed;
206 .escape => c.SDLK_ESCAPE, 202 if (k.code >= 0x20 and k.code < 0x7f) {
207 .backspace => c.SDLK_BACKSPACE, 203 const letter = [_]u8{@intCast(k.code)};
208 .up => c.SDLK_UP, 204 try self.inject(win, .{ .text = &letter });
209 .down => c.SDLK_DOWN, 205 }
210 .left => c.SDLK_LEFT, 206 ev.key.type = c.SDL_EVENT_KEY_UP;
211 .right => c.SDLK_RIGHT, 207 },
212 else => return, 208 .click => |at| {
213 }; 209 ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_DOWN;
210 ev.button.button = c.SDL_BUTTON_LEFT;
211 ev.button.x = at.x;
212 ev.button.y = at.y;
213 },
214 .state => |path| {
215 const copy = try self.alloc.dupe(u8, path);
216 if (self.state) |old| self.alloc.free(old);
217 self.state = copy;
218 return;
214 }, 219 },
215 .resize => |r| { 220 .resize => |r| {
216 if (!c.SDL_SetWindowSize(win, @intCast(r.w), @intCast(r.h))) return error.WindowResizeFailed; 221 if (!c.SDL_SetWindowSize(win, @intCast(r.w), @intCast(r.h))) return error.WindowResizeFailed;
@@ -231,92 +236,167 @@ const HookReader = struct {
231 } 236 }
232 }; 237 };
233 238
234 fn setTitle(win: *c.SDL_Window, session: []const u8, reconnecting: bool, bell: bool) void {
235 var buf: [192]u8 = undefined;
236 const title = std.fmt.bufPrintZ(&buf, "muxg {s}{s}{s}", .{ session, if (reconnecting) " [reconnecting]" else "", if (bell) " [bell]" else "" }) catch "muxg";
237 _ = c.SDL_SetWindowTitle(win, title.ptr);
238 }
239
240 fn sdlFail(op: []const u8) u8 { 239 fn sdlFail(op: []const u8) u8 {
241 std.debug.print("muxg: {s}: {s}\n", .{ op, std.mem.span(c.SDL_GetError()) }); 240 std.debug.print("muxg: {s}: {s}\n", .{ op, std.mem.span(c.SDL_GetError()) });
242 return 2; 241 return 2;
243 } 242 }
244 243
244 /// This state belongs to the window thread. Geometry and input select stable
245 /// pane IDs; transport ownership lives in Runtime and rendering uses snapshots.
245 const Events = struct { 246 const Events = struct {
246 pump: *session_pump.Pump, 247 rt: *runtime.Runtime,
247 win: *c.SDL_Window, 248 win: *c.SDL_Window,
248 wake: *Wake, 249 wake: *Wake,
249 hook: ?*HookReader, 250 hook: ?*HookReader,
250 cache: *font.GlyphCache, 251 cache: *font.GlyphCache,
251 base_font_px: u16, 252 base_font_px: u16,
252 cells: CellsOf, 253 staged: ?model.Identity = null,
254 layout: model.Layout = .{},
255 metrics: model.Metrics,
253 fb_w: c_int, 256 fb_w: c_int,
254 fb_h: c_int, 257 fb_h: c_int,
255 dirty: bool = true, 258 dirty: bool = true,
256 geometry_dirty: bool = false, 259 geometry_dirty: bool = false,
257 suppress_text: bool = false, 260 suppress_text: bool = false,
261 command_mode: bool = false,
262 consumed_key: ?u32 = null,
263 notice: []const u8 = "",
264
265 fn command(self: *Events, key: u32) !void {
266 self.command_mode = false;
267 self.notice = "";
268 const ws = &self.rt.workspace;
269 switch (key) {
270 c.SDLK_H, c.SDLK_LEFT => ws.moveFocus(&self.layout, .left),
271 c.SDLK_J, c.SDLK_DOWN => ws.moveFocus(&self.layout, .down),
272 c.SDLK_K, c.SDLK_UP => ws.moveFocus(&self.layout, .up),
273 c.SDLK_L, c.SDLK_RIGHT => ws.moveFocus(&self.layout, .right),
274 c.SDLK_V => ws.arm(.beside),
275 c.SDLK_B => ws.arm(.stacked),
276 c.SDLK_ESCAPE => ws.cancel(),
277 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
278 if (ws.tab().pending != null) {
279 if (self.staged) |*target| {
280 _ = self.rt.add(target.target, target.session, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics) catch |err| {
281 self.notice = @errorName(err);
282 self.dirty = true;
283 return;
284 };
285 target.deinit();
286 self.staged = null;
287 try self.relayout();
288 } else self.notice = "No staged target";
289 }
290 },
291 else => self.notice = "v/b split, h/j/k/l focus, Enter insert, Esc cancel",
292 }
293 self.dirty = true;
294 }
258 295
259 fn handle(self: *Events, ev: c.SDL_Event) !bool { 296 fn handle(self: *Events, ev: c.SDL_Event) !bool {
260 switch (ev.type) { 297 switch (ev.type) {
261 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => { 298 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
262 try self.pump.say(.detach);
263 return false;
264 },
265 c.SDL_EVENT_TEXT_INPUT => { 299 c.SDL_EVENT_TEXT_INPUT => {
266 defer if (self.hook) |h| h.releaseText(ev.text.text); 300 defer if (self.hook) |h| h.releaseText(ev.text.text);
267 if (!self.suppress_text) try self.pump.say(.{ .input = std.mem.span(ev.text.text) }); 301 if (!self.suppress_text and !self.command_mode) try self.rt.input(std.mem.span(ev.text.text));
268 self.suppress_text = false; 302 self.suppress_text = false;
269 }, 303 },
270 c.SDL_EVENT_KEY_DOWN => { 304 c.SDL_EVENT_KEY_DOWN => {
271 self.suppress_text = false; 305 self.suppress_text = false;
272 // Event keycodes normally ignore Shift. Ask the active layout 306 const key = ev.key.key;
273 // for its translated character before encoding a modifier chord. 307 if (self.consumed_key == key) {
274 const translated = if (ev.key.scancode != c.SDL_SCANCODE_UNKNOWN) 308 self.suppress_text = true;
275 c.SDL_GetKeyFromScancode(ev.key.scancode, ev.key.mod, false) 309 return true;
276 else 310 }
277 ev.key.key; 311 const prefix = key == c.SDLK_SPACE and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & c.SDL_KMOD_SHIFT != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
278 if (keyEvent(translated, ev.key.mod)) |key| { 312 if (prefix) {
279 var buf: [keymap.max_seq_len]u8 = undefined; 313 self.suppress_text = true;
280 const bytes = keymap.encode(key, &buf); 314 if (!ev.key.repeat) {
281 if (bytes.len != 0) try self.pump.say(.{ .input = bytes }); 315 if (self.command_mode) {
282 self.suppress_text = key.key == .char; 316 self.command_mode = false;
317 try self.sendKey(keyEvent(key, ev.key.mod).?);
318 } else self.command_mode = true;
319 self.notice = "";
320 self.dirty = true;
321 }
322 } else if (self.command_mode) {
323 self.consumed_key = key;
324 self.suppress_text = true;
325 try self.command(key);
326 } else if (key == c.SDLK_ESCAPE and self.rt.workspace.tab().pending != null) {
327 self.rt.workspace.cancel();
328 self.dirty = true;
329 } else {
330 const translated = if (ev.key.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.key.scancode, ev.key.mod, false) else key;
331 if (keyEvent(translated, ev.key.mod)) |mapped| {
332 try self.sendKey(mapped);
333 self.suppress_text = mapped.key == .char;
334 }
335 }
336 },
337 c.SDL_EVENT_KEY_UP => {
338 if (self.consumed_key == ev.key.key) self.consumed_key = null;
339 self.suppress_text = false;
340 },
341 c.SDL_EVENT_WINDOW_FOCUS_LOST => {
342 self.suppress_text = false;
343 self.command_mode = false;
344 self.consumed_key = null;
345 self.dirty = true;
346 },
347 c.SDL_EVENT_MOUSE_BUTTON_DOWN => if (ev.button.button == c.SDL_BUTTON_LEFT) {
348 var w: c_int = 0;
349 var h: c_int = 0;
350 if (c.SDL_GetWindowSize(self.win, &w, &h)) {
351 const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h);
352 if (self.layout.hit(at.x, at.y)) |id| _ = self.rt.workspace.focus(id);
353 self.dirty = true;
283 } 354 }
284 }, 355 },
285 c.SDL_EVENT_KEY_UP => self.suppress_text = false,
286 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.suppress_text = false,
287 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, 356 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true,
288 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, 357 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true,
289 else => if (ev.type == self.wake.event_type) { 358 else => if (ev.type == self.wake.event_type and self.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) })) {
290 self.dirty = true; 359 self.dirty = true;
291 }, 360 },
292 } 361 }
293 return true; 362 return true;
294 } 363 }
295 364 fn sendKey(self: *Events, key: keymap.Event) !void {
296 /// Resize and scale notifications can arrive together in either order. 365 var buf: [keymap.max_seq_len]u8 = undefined;
297 /// Query once after the event batch, then use the same physical metrics 366 const bytes = keymap.encode(key, &buf);
298 /// for the daemon's size claim and this frame's glyphs. 367 if (bytes.len != 0) try self.rt.input(bytes);
368 }
369 fn relayout(self: *Events) !void {
370 self.layout = self.rt.workspace.layout(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
371 try self.rt.resize(&self.layout);
372 self.dirty = true;
373 }
299 fn refreshGeometry(self: *Events) !void { 374 fn refreshGeometry(self: *Events) !void {
300 if (!self.geometry_dirty) return; 375 if (!self.geometry_dirty) return;
301 var fb_w: c_int = 0; 376 var w: c_int = 0;
302 var fb_h: c_int = 0; 377 var h: c_int = 0;
303 if (!c.SDL_GetWindowSizeInPixels(self.win, &fb_w, &fb_h)) return error.WindowSizeFailed; 378 if (!c.SDL_GetWindowSizeInPixels(self.win, &w, &h)) return error.WindowSizeFailed;
304 try self.updateGeometry(fb_w, fb_h, c.SDL_GetWindowDisplayScale(self.win)); 379 try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win));
305 } 380 }
306 381 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void {
307 fn updateGeometry(self: *Events, fb_w: c_int, fb_h: c_int, display_scale: f32) !void { 382 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale));
308 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, display_scale)); 383 self.fb_w = w;
309 self.fb_w = fb_w; 384 self.fb_h = h;
310 self.fb_h = fb_h; 385 self.metrics = measuredMetrics(self.cache.face, scale);
311 const now = cellsOf(@intCast(@max(self.fb_w, 1)), @intCast(@max(self.fb_h, 1)), self.cache.face.cell_w, self.cache.face.cell_h); 386 try self.relayout();
312 if (!std.meta.eql(now, self.cells)) {
313 try self.pump.say(.{ .resize = .{ .cols = now.cols, .rows = now.rows } });
314 self.cells = now;
315 }
316 self.geometry_dirty = false; 387 self.geometry_dirty = false;
317 self.dirty = true;
318 } 388 }
319 }; 389 };
390 fn measuredMetrics(face: *const font.Face, scale: f32) model.Metrics {
391 return .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .divider = @intFromFloat(@round(if (std.math.isFinite(scale)) std.math.clamp(scale, 1, 32) else 1)) };
392 }
393 fn physicalPoint(x: f32, y: f32, logical_w: c_int, logical_h: c_int, fb_w: c_int, fb_h: c_int) struct { x: u32, y: u32 } {
394 return .{ .x = physicalAxis(x, logical_w, fb_w), .y = physicalAxis(y, logical_h, fb_h) };
395 }
396 fn physicalAxis(v: f32, logical: c_int, pixels: c_int) u32 {
397 if (!std.math.isFinite(v) or v < 0 or logical <= 0 or pixels <= 0) return std.math.maxInt(u32);
398 return @intFromFloat(@min(@floor(v * @as(f32, @floatFromInt(pixels)) / @as(f32, @floatFromInt(logical))), 2147483648));
399 }
320 400
321 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { 401 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
322 var ring: bench.Ring = .{}; 402 var ring: bench.Ring = .{};
@@ -366,16 +446,19 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
366 var fb_w: c_int = 0; 446 var fb_w: c_int = 0;
367 var fb_h: c_int = 0; 447 var fb_h: c_int = 0;
368 if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels"); 448 if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels");
369 const cells = cellsOf(@intCast(@max(fb_w, 1)), @intCast(@max(fb_h, 1)), face.cell_w, face.cell_h); 449 const metrics = measuredMetrics(&face, c.SDL_GetWindowDisplayScale(win));
370 var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) }; 450 var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) };
371 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents"); 451 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents");
372 const pump = try session_pump.Pump.start(alloc, .{ .target = opts.target, .session = opts.session, .cols = cells.cols, .rows = cells.rows, .wake = Wake.ring, .wake_ctx = &wake }); 452 var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring });
373 defer pump.stop(); 453 defer rt.deinit();
454 _ = try rt.add(opts.target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
374 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 455 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
375 defer if (hook) |*h| h.deinit(); 456 defer if (hook) |*h| h.deinit();
376 var events: Events = .{ .pump = pump, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .cells = cells, .fb_w = fb_w, .fb_h = fb_h }; 457 var events: Events = .{ .rt = &rt, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h };
377 var last_phase: ?session_pump.Phase = null; 458 if (opts.next) |next| events.staged = try model.Identity.init(alloc, next.target, next.session);
378 var bell_until: i64 = 0; 459 defer if (events.staged) |*target| target.deinit();
460 try events.relayout();
461 var headers: [model.max_panes]Header = @splat(.{});
379 var visible_blink = false; 462 var visible_blink = false;
380 var blink_phase = true; 463 var blink_phase = true;
381 var blink_until: i64 = 0; 464 var blink_until: i64 = 0;
@@ -395,33 +478,14 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
395 } 478 }
396 } 479 }
397 try events.refreshGeometry(); 480 try events.refreshGeometry();
398 // Clearing before painting lets a concurrent apply queue the next wake.
399 if (wake.pending.swap(false, .acq_rel)) events.dirty = true;
400 if (usr1_seen.swap(false, .acq_rel)) report(&ring); 481 if (usr1_seen.swap(false, .acq_rel)) report(&ring);
401 const now = std.time.milliTimestamp(); 482 const now = std.time.milliTimestamp();
402 const state = pump.state(); 483 events.dirty = rt.poll(now) or events.dirty;
403 if (last_phase == null or last_phase.? != state.phase) { 484 if (hook) |*h| if (h.state) |path| {
404 last_phase = state.phase; 485 defer alloc.free(path);
405 switch (state.phase) { 486 h.state = null;
406 .exited => return state.exit_code, 487 try writeState(alloc, path, &events);
407 .refused, .failed, .dial_failed => { 488 };
408 std.debug.print("muxg: {s}\n", .{state.reasonText()});
409 return if (state.phase == .dial_failed) 2 else 1;
410 },
411 .taken => {
412 std.debug.print("muxg: the session was taken by another client\n", .{});
413 return 0;
414 },
415 else => setTitle(win, opts.session, state.phase == .reconnecting, bell_until != 0),
416 }
417 }
418 if (state.bell) {
419 bell_until = now + 200;
420 setTitle(win, opts.session, state.phase == .reconnecting, true);
421 } else if (bell_until != 0 and now >= bell_until) {
422 bell_until = 0;
423 setTitle(win, opts.session, state.phase == .reconnecting, false);
424 }
425 if (visible_blink and now >= blink_until) { 489 if (visible_blink and now >= blink_until) {
426 blink_phase = !blink_phase; 490 blink_phase = !blink_phase;
427 blink_until = now + 500; 491 blink_until = now + 500;
@@ -434,54 +498,71 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
434 instances.clearRetainingCapacity(); 498 instances.clearRetainingCapacity();
435 lists.backgrounds.clearRetainingCapacity(); 499 lists.backgrounds.clearRetainingCapacity();
436 lists.foregrounds.clearRetainingCapacity(); 500 lists.foregrounds.clearRetainingCapacity();
437 { 501 // Freeze each pane under its own mutex. No transport locks are held
438 pump.mu.lock(); 502 // during shaping, atlas growth, instance generation, or GL calls.
439 defer pump.mu.unlock(); 503 for (events.layout.items(), 0..) |p, i| {
440 timing.apply_us = @intCast(@min(pump.last_apply_us, std.math.maxInt(u32))); 504 const live = rt.get(p.id).?;
441 const grid = pump.grid; 505 if (p.visible.w == 0 or p.visible.h == 0) continue;
442 const rows = @min(grid.rows, events.cells.rows); 506 timing.apply_us = @max(timing.apply_us, try live.capture(p.cols, p.rows));
443 const cols = @min(grid.cols, events.cells.cols); 507 headers[i].set(&events, p, live);
444 // Every glyph must be inserted before UV normalization for this frame. 508 }
445 for (0..rows) |y| { 509 // Prepare ALL pane and header glyphs before normalizing ANY UV. A
446 const row = grid.row(@intCast(y)); 510 // later pane may grow the shared atlas and change every earlier UV.
447 for (row.cells[0..cols]) |cell| { 511 for (events.layout.items(), 0..) |p, i| {
448 if (cell.text_len == 0 or cell.wide == .spacer_tail) continue; 512 if (p.visible.w == 0 or p.visible.h == 0) continue;
449 try cache.prepare(row.textOf(cell), @enumFromInt(cell.style.flags & 3)); 513 try prepareGrid(&cache, rt.get(p.id).?.snapshot);
450 } 514 var header_row = headers[i].row();
451 } 515 try prepareRow(&cache, &header_row);
452 const qctx: 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 }; 516 }
453 const had_blink = visible_blink; 517 const had_blink = visible_blink;
454 visible_blink = false; 518 visible_blink = false;
455 for (0..rows) |y| { 519 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 };
456 const blinking = try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), cols, 0, @intCast(y), qctx); 520 for (events.layout.items(), 0..) |p, i| {
457 visible_blink = visible_blink or blinking; 521 if (p.visible.w == 0 or p.visible.h == 0) continue;
458 } 522 const live = rt.get(p.id).?;
459 if (visible_blink and !had_blink) blink_until = now + 500; 523 const grid = live.snapshot;
460 if (!visible_blink) blink_phase = true; 524 // Opaque content rectangles leave the clear color in dividers.
461 if (grid.cursor.x < cols and grid.cursor.y < rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, qctx)); 525 try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(p.content.x), @floatFromInt(p.content.y), @floatFromInt(p.content.w), @floatFromInt(p.content.h), 0x101010ff));
526 var ctx = base_ctx;
527 ctx.x0 = @floatFromInt(p.content.x);
528 ctx.y0 = @floatFromInt(p.content.y);
529 const bg_start = lists.backgrounds.items.len;
530 const fg_start = lists.foregrounds.items.len;
531 for (0..grid.rows) |y| visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink;
532 if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx));
533 clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible));
534 ctx.x0 = @floatFromInt(p.header.x);
535 ctx.y0 = @floatFromInt(p.header.y);
536 ctx.default_bg = if (rt.workspace.tab().focus == p.id) 0x304860ff else 0x24282cff;
537 if (live.bell_until != 0) ctx.default_bg = 0x705020ff;
538 try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(p.header.w), @floatFromInt(p.header.h), ctx.default_bg));
539 const header_start = lists.foregrounds.items.len;
540 var header_row = headers[i].row();
541 _ = try quads.rowInstances(&lists, alloc, &header_row, @intCast(header_row.cells.len), 0, 0, ctx);
542 clipPane(&lists, lists.backgrounds.items.len, header_start, model.Rect.intersect(p.header, p.visible));
462 } 543 }
544 if (visible_blink and !had_blink) blink_until = now + 500;
545 if (!visible_blink) blink_phase = true;
463 try lists.flatten(&instances, alloc); 546 try lists.flatten(&instances, alloc);
464 timing.rebuild_us = bench.usSince(&timer); 547 timing.rebuild_us = bench.usSince(&timer);
465 if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas); 548 if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas);
466 timing.atlas_us = bench.usSince(&timer); 549 timing.atlas_us = bench.usSince(&timer);
467 renderer.uploadInstances(instances.items); 550 renderer.uploadInstances(instances.items);
468 timing.upload_us = bench.usSince(&timer); 551 timing.upload_us = bench.usSince(&timer);
469 renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x101010ff); 552 renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x687888ff);
470 if (hook) |*h| if (h.capture) |path| { 553 if (hook) |*h| if (h.capture) |path| {
471 defer alloc.free(path); 554 defer alloc.free(path);
472 h.capture = null; 555 h.capture = null;
473 const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h)); 556 const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h));
474 defer alloc.free(pixels); 557 defer alloc.free(pixels);
475 const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path});
476 defer alloc.free(temporary);
477 const file = try std.fs.cwd().createFile(temporary, .{});
478 defer file.close();
479 var header: [64]u8 = undefined; 558 var header: [64]u8 = undefined;
480 try file.writeAll(try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ events.fb_w, events.fb_h })); 559 try writeArtifact(alloc, path, &.{ try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ events.fb_w, events.fb_h }), pixels });
481 try file.writeAll(pixels);
482 try std.fs.cwd().rename(temporary, path);
483 }; 560 };
484 if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow"); 561 if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow");
562 for (events.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) {
563 const live = rt.get(p.id).?;
564 live.painted_seq = live.snapshot_seq;
565 };
485 timing.draw_us = bench.usSince(&timer); 566 timing.draw_us = bench.usSince(&timer);
486 ring.record(timing); 567 ring.record(timing);
487 } 568 }
@@ -506,60 +587,240 @@ test "key mapping sends modifier punctuation and space through the shared encode
506 try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null); 587 try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null);
507 } 588 }
508 589
509 test "drawable dimensions floor, clamp small windows, and cap wire columns" { 590 test "test hook rejects zero resize and retains text exactly" {
510 try std.testing.expectEqual(CellsOf{ .cols = 120, .rows = 37 }, cellsOf(963, 601, 8, 16));
511 try std.testing.expectEqual(CellsOf{ .cols = 1, .rows = 1 }, cellsOf(3, 5, 8, 16));
512 try std.testing.expectEqual(term.protocol.max_cols, cellsOf(100000, 800, 1, 16).cols);
513 try std.testing.expect(parseHook("resize:0x400") == null); 591 try std.testing.expect(parseHook("resize:0x400") == null);
514 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text); 592 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text);
515 } 593 }
516 594
517 test "display-scale events rebuild physical font metrics and coalesce drawable resize" { 595 const Header = struct {
518 const alloc = std.testing.allocator; 596 bytes: [512]u8 = undefined,
597 cells: [512]term.grid.Cell = undefined,
598 len: usize = 0,
599 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
600 const focused = events.rt.workspace.tab().focus == p.id;
601 const pending = events.rt.workspace.tab().pending;
602 const hint = if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, Enter insert, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (pending.?.direction == .beside) " [split beside: prefix Enter inserts, Esc cancels]" else " [split below: prefix Enter inserts, Esc cancels]") else events.notice;
603 const label = events.rt.workspace.pane(p.id).?.identity.label;
604 var status_buf: [48]u8 = undefined;
605 const status: []const u8 = switch (live.status.phase) {
606 .attached => "",
607 .dialing => "[connecting] ",
608 .reconnecting => "[reconnecting] ",
609 .dial_failed => "[unreachable] ",
610 .failed => "[failed] ",
611 .refused => "[refused] ",
612 .taken => "[taken by another client] ",
613 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable,
614 };
615 const text = std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..];
616 self.len = @min(text.len, @min(self.cells.len, p.header.w / events.metrics.cell_w));
617 for (self.bytes[0..self.len], 0..) |*ch, i| {
618 if (ch.* < 0x20 or ch.* >= 0x7f) ch.* = '?';
619 self.cells[i] = .{ .text_off = @intCast(i), .text_len = 1 };
620 }
621 }
622 fn row(self: *Header) term.grid.Row {
623 return .{ .cells = self.cells[0..self.len], .text = .{ .items = self.bytes[0..self.len], .capacity = 0 } };
624 }
625 };
626 fn prepareRow(cache: *font.GlyphCache, row: *const term.grid.Row) !void {
627 for (row.cells) |cell| {
628 if (cell.text_len == 0 or cell.wide == .spacer_tail) continue;
629 try cache.prepare(row.textOf(cell), quads.variantOf(cell.style.flags));
630 }
631 }
632 fn prepareGrid(cache: *font.GlyphCache, grid: *const term.grid.Grid) !void {
633 for (grid.lines) |*row| try prepareRow(cache, row);
634 }
635 fn clipPane(lists: *quads.Lists, bg: usize, fg: usize, rect: model.Rect) void {
636 quads.clip(&lists.backgrounds, bg, @floatFromInt(rect.x), @floatFromInt(rect.y), @floatFromInt(rect.w), @floatFromInt(rect.h));
637 quads.clip(&lists.foregrounds, fg, @floatFromInt(rect.x), @floatFromInt(rect.y), @floatFromInt(rect.w), @floatFromInt(rect.h));
638 }
639 fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void {
640 // Called before capture/rebuild, without forcing dirty. Snapshot and sequence
641 // are those of the last completed draw, rather than a newer live replica.
642 var arena = std.heap.ArenaAllocator.init(alloc);
643 defer arena.deinit();
644 const a = arena.allocator();
645 const PaneState = struct {
646 id: model.PaneId,
647 generation: u64,
648 label: []const u8,
649 outer: model.Rect,
650 header: model.Rect,
651 content: model.Rect,
652 visible: model.Rect,
653 cols: u16,
654 rows: u16,
655 phase: []const u8,
656 exit_code: u8,
657 painted_seq: u64,
658 painted_text: []const u8,
659 };
660 var panes: [model.max_panes]PaneState = undefined;
661 for (events.layout.items(), 0..) |p, i| {
662 const live = events.rt.get(p.id).?;
663 panes[i] = .{ .id = p.id, .generation = live.key.generation, .label = events.rt.workspace.pane(p.id).?.identity.label, .outer = p.outer, .header = p.header, .content = p.content, .visible = p.visible, .cols = p.cols, .rows = p.rows, .phase = @tagName(live.status.phase), .exit_code = live.status.exit_code, .painted_seq = live.painted_seq, .painted_text = if (live.painted_seq == 0) "" else try live.snapshot.dumpPlain(a) };
664 }
665 var w: c_int = 0;
666 var h: c_int = 0;
667 _ = c.SDL_GetWindowSize(events.win, &w, &h);
668 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.fb_w, .height = events.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.metrics.cell_w, .cell_h = events.metrics.cell_h, .divider = events.metrics.divider, .header_h = events.metrics.cell_h, .tab = events.rt.workspace.active_tab_id, .focus = events.rt.workspace.tab().focus, .pending = events.rt.workspace.tab().pending, .command_mode = events.command_mode, .panes = panes[0..events.layout.len] }, .{});
669 try writeArtifact(a, path, &.{bytes});
670 }
671 fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void {
672 const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path});
673 defer alloc.free(temporary);
674 const file = try std.fs.cwd().createFile(temporary, .{});
675 defer file.close();
676 errdefer std.fs.cwd().deleteFile(temporary) catch {};
677 for (parts) |bytes| try file.writeAll(bytes);
678 try std.fs.cwd().rename(temporary, path);
679 }
680
681 test "logical pointer coordinates use drawable density independently of font scale" {
682 const p = physicalPoint(480, 300, 960, 600, 1920, 1200);
683 try std.testing.expectEqual(@as(u32, 960), p.x);
684 try std.testing.expectEqual(@as(u32, 600), p.y);
685 // A content scale of two with a density of one must keep these unchanged.
686 try std.testing.expectEqual(@as(u32, 480), physicalPoint(480, 300, 960, 600, 960, 600).x);
687 try std.testing.expectEqual(std.math.maxInt(u32), physicalAxis(-1, 960, 1920));
688 }
689
690 test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" {
691 const a = std.testing.allocator;
519 var face = try font.Face.open(16); 692 var face = try font.Face.open(16);
520 defer face.deinit(); 693 defer face.deinit();
521 var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(16), 256); 694 var glyph_atlas = try atlas.Atlas.init(a, font.atlasWidth(16), 256);
522 defer glyph_atlas.deinit(alloc); 695 defer glyph_atlas.deinit(a);
523 var cache: font.GlyphCache = .{ .alloc = alloc, .face = &face, .glyph_atlas = &glyph_atlas }; 696 var cache: font.GlyphCache = .{ .alloc = a, .face = &face, .glyph_atlas = &glyph_atlas };
524 defer cache.deinit(); 697 defer cache.deinit();
525 const initial = cellsOf(960, 600, face.cell_w, face.cell_h); 698 var rt = runtime.Runtime.init(a, .{});
526 const pump = try session_pump.Pump.start(alloc, .{ .target = .{ .via = "cat" }, .cols = initial.cols, .rows = initial.rows }); 699 defer rt.deinit();
527 defer pump.stop(); 700 const metrics = measuredMetrics(&face, 1);
701 const first = try rt.add(.{ .via = "cat" }, "left", 960, 600, metrics);
702 rt.workspace.arm(.beside);
703 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics);
528 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 704 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
529 var events: Events = .{ .pump = pump, .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .cells = initial, .fb_w = 960, .fb_h = 600, .dirty = false }; 705 var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .metrics = metrics, .fb_w = 960, .fb_h = 600 };
706 try events.relayout();
707 const initial = events.layout;
708 const old_key = rt.get(first).?.key;
530 var event = std.mem.zeroes(c.SDL_Event); 709 var event = std.mem.zeroes(c.SDL_Event);
531 event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED; 710 event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED;
532 try std.testing.expect(try events.handle(event)); 711 try std.testing.expect(try events.handle(event));
533 try std.testing.expect(events.geometry_dirty); 712 try std.testing.expect(events.geometry_dirty);
534 event.type = c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED;
535 try std.testing.expect(try events.handle(event));
536 // A scale-only change must alter the cell claim even if the compositor
537 // has not changed the framebuffer size yet.
538 try events.updateGeometry(960, 600, 2); 713 try events.updateGeometry(960, 600, 2);
539 try std.testing.expect(events.cells.cols < initial.cols); 714 for (events.layout.items(), initial.items()) |p, before| {
540 try std.testing.expect(events.cells.rows < initial.rows); 715 try std.testing.expect(p.cols < before.cols and p.rows < before.rows);
716 try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols);
717 try std.testing.expectEqual(p.rows, rt.get(p.id).?.size.rows);
718 }
541 try events.updateGeometry(1920, 1200, 2); 719 try events.updateGeometry(1920, 1200, 2);
542 try std.testing.expectEqual(@as(u16, 32), face.pixels); 720 for (events.layout.items(), initial.items()) |p, before| {
543 // Hinting rounds metrics to whole pixels at each size; doubling the 721 try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1));
544 // raster and drawable preserves the grid within that small rounding gap. 722 try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1));
545 try std.testing.expect(@abs(@as(i32, events.cells.cols) - initial.cols) <= @max(@as(u32, initial.cols) / 8, 1)); 723 try std.testing.expectEqual(@as(u32, face.cell_h), p.content.y);
546 try std.testing.expect(@abs(@as(i32, events.cells.rows) - initial.rows) <= @max(@as(u32, initial.rows) / 8, 1)); 724 }
547 try std.testing.expectEqual(cellsOf(1920, 1200, face.cell_w, face.cell_h), events.cells);
548 try std.testing.expect(events.dirty);
549 try std.testing.expect(!events.geometry_dirty);
550
551 // A drawable resize still updates the grid when rounding leaves the
552 // raster size unchanged, and it preserves the prepared glyph cache.
553 try cache.prepare("M", .regular); 725 try cache.prepare("M", .regular);
554 try events.updateGeometry(1600, 1000, 2.001); 726 try events.updateGeometry(1600, 1000, 2.001);
555 try std.testing.expectEqual(@as(u16, 32), face.pixels);
556 try std.testing.expectEqual(@as(usize, 1), cache.runs.count()); 727 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
557 try std.testing.expectEqual(cellsOf(1600, 1000, face.cell_w, face.cell_h), events.cells); 728 try std.testing.expectEqual(@as(u16, 32), face.pixels);
558 try events.updateGeometry(1200, 750, 1.25);
559 try std.testing.expectEqual(@as(u16, 20), face.pixels);
560 try std.testing.expectEqual(@as(usize, 0), cache.runs.count());
561 try std.testing.expectEqual(cellsOf(1200, 750, face.cell_w, face.cell_h), events.cells);
562 try events.updateGeometry(960, 600, 1); 729 try events.updateGeometry(960, 600, 1);
563 try std.testing.expectEqual(@as(u16, 16), face.pixels); 730 try std.testing.expectEqualDeep(initial.items(), events.layout.items());
564 try std.testing.expectEqual(initial, events.cells); 731 try std.testing.expect(rt.accepts(old_key));
732 // Removal joins before destroying the identity/context. A queued old key
733 // cannot select a new slot occupant, even after a further insertion.
734 rt.remove(first);
735 try std.testing.expect(!rt.accepts(old_key));
736 rt.workspace.arm(.stacked);
737 const third = try rt.add(.{ .via = "cat" }, "third", 960, 600, metrics);
738 try std.testing.expect(third != first and third != second);
739 try std.testing.expect(!rt.accepts(old_key));
740 }
741
742 test "command key text is consumed and subsequent ordinary text is not suppressed" {
743 const a = std.testing.allocator;
744 var rt = runtime.Runtime.init(a, .{});
745 defer rt.deinit();
746 // No live attachment is needed to test the actual SDL event routing state.
747 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
748 var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600 };
749 var event = std.mem.zeroes(c.SDL_Event);
750 event.key.type = c.SDL_EVENT_KEY_DOWN;
751 event.key.key = c.SDLK_SPACE;
752 event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
753 try std.testing.expect(try events.handle(event));
754 try std.testing.expect(events.command_mode and events.suppress_text);
755 event.text.type = c.SDL_EVENT_TEXT_INPUT;
756 event.text.text = " ";
757 try std.testing.expect(try events.handle(event));
758 event = std.mem.zeroes(c.SDL_Event);
759 event.key.type = c.SDL_EVENT_KEY_DOWN;
760 event.key.key = c.SDLK_H;
761 try std.testing.expect(try events.handle(event));
762 try std.testing.expect(!events.command_mode and events.suppress_text);
763 event.text.type = c.SDL_EVENT_TEXT_INPUT;
764 event.text.text = "h";
765 try std.testing.expect(try events.handle(event));
766 try std.testing.expect(!events.suppress_text);
767 event = std.mem.zeroes(c.SDL_Event);
768 event.key.type = c.SDL_EVENT_KEY_DOWN;
769 event.key.key = c.SDLK_H;
770 event.key.repeat = true;
771 try std.testing.expect(try events.handle(event));
772 try std.testing.expect(events.suppress_text);
773 event.key.type = c.SDL_EVENT_KEY_UP;
774 try std.testing.expect(try events.handle(event));
775 try std.testing.expect(events.consumed_key == null);
776 event.key.type = c.SDL_EVENT_KEY_DOWN;
777 event.key.repeat = false;
778 event.key.key = c.SDLK_X;
779 try std.testing.expect(try events.handle(event));
780 try std.testing.expect(!events.suppress_text);
781 // Repeated prefix exits mode; its shared keymap encoding is NUL.
782 // The integration fixture verifies the emitted byte count with a real PTY.
783 event.key.key = c.SDLK_SPACE;
784 event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
785 try std.testing.expect(try events.handle(event));
786 try std.testing.expect(events.command_mode);
787 try std.testing.expect(try events.handle(event));
788 try std.testing.expect(!events.command_mode);
789 var bytes: [keymap.max_seq_len]u8 = undefined;
790 try std.testing.expectEqualSlices(u8, &.{0}, keymap.encode(keyEvent(c.SDLK_SPACE, event.key.mod).?, &bytes));
791 }
792
793 test "later pane atlas growth precedes earlier pane UV generation" {
794 const a = std.testing.allocator;
795 var face = try font.Face.open(16);
796 defer face.deinit();
797 var glyph_atlas = try atlas.Atlas.init(a, 128, 1);
798 defer glyph_atlas.deinit(a);
799 var cache: font.GlyphCache = .{ .alloc = a, .face = &face, .glyph_atlas = &glyph_atlas };
800 defer cache.deinit();
801 const left = try term.grid.Grid.init(a, 1, 1);
802 defer left.deinit();
803 try left.lines[0].text.appendSlice(a, "M");
804 left.lines[0].cells[0] = .{ .text_len = 1 };
805 const right = try term.grid.Grid.init(a, 94, 1);
806 defer right.deinit();
807 for (right.lines[0].cells, 0..) |*cell, i| {
808 try right.lines[0].text.append(a, @intCast(33 + i));
809 cell.* = .{ .text_off = @intCast(i), .text_len = 1, .style = .{ .flags = 3 } };
810 }
811 try prepareGrid(&cache, left);
812 const before = glyph_atlas.height;
813 try prepareGrid(&cache, right);
814 try std.testing.expect(glyph_atlas.height > before);
815 const shaped = try font.GlyphCache.resolve(@ptrCast(&cache), "M", .regular);
816 const entry = shaped[0].entry;
817 const 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 } };
818 var lists: quads.Lists = .{};
819 defer lists.deinit(a);
820 _ = try quads.rowInstances(&lists, a, left.row(0), 1, 0, 0, ctx);
821 try std.testing.expect(lists.foregrounds.items.len > 0);
822 const glyph = lists.foregrounds.items[0];
823 const expected = @as(f32, @floatFromInt(entry.y)) / @as(f32, @floatFromInt(glyph_atlas.height));
824 try std.testing.expectApproxEqAbs(expected, glyph.v0, 0.00001);
825 try std.testing.expect(glyph.v1 <= @as(f32, @floatFromInt(entry.y + entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height)));
565 } 826 }
src/gui/native.zig
Old New
@@ -1,9 +1,8 @@
1 //! The native client's painter: a window onto one daemon session, rendered 1 //! The native client's workspace and painter. Each pane renders the grid
2 //! directly from the grid delivered on the wire. 2 //! delivered by its own session connection.
3 //! 3 //!
4 //! Folder rules 8 and 9 in build.zig keep this directory limited to rendering 4 //! Folder rules 8 and 9 in build.zig keep this workspace independent of the
5 //! and window integration for a single session. Multi-session placement and 5 //! terminal wall and confine the window API to frame.zig. The
6 //! discovery remain outside it. The window API is confined to frame.zig; the
7 //! remaining painter code stays usable in unit tests that open no window. 6 //! remaining painter code stays usable in unit tests that open no window.
8 //! 7 //!
9 //! Imports `client` and `term` and nothing else of ours. 8 //! Imports `client` and `term` and nothing else of ours.
@@ -12,6 +11,8 @@ const client = @import("client");
12 const term = @import("term"); 11 const term = @import("term");
13 12
14 pub const frame = @import("frame.zig"); 13 pub const frame = @import("frame.zig");
14 pub const workspace = @import("workspace.zig");
15 pub const runtime = @import("runtime.zig");
15 pub const bench = @import("bench.zig"); 16 pub const bench = @import("bench.zig");
16 pub const atlas = @import("atlas.zig"); 17 pub const atlas = @import("atlas.zig");
17 pub const font = @import("font.zig"); 18 pub const font = @import("font.zig");
@@ -26,6 +27,8 @@ test {
26 _ = client; 27 _ = client;
27 _ = term; 28 _ = term;
28 _ = frame; 29 _ = frame;
30 _ = workspace;
31 _ = runtime;
29 _ = bench; 32 _ = bench;
30 _ = atlas; 33 _ = atlas;
31 _ = font; 34 _ = font;
src/gui/quads.zig
Old New
@@ -54,7 +54,7 @@ pub fn rgbaOf(c: u32, d: u32) u32 {
54 else => d, 54 else => d,
55 }; 55 };
56 } 56 }
57 fn solid(x: f32, y: f32, w: f32, h: f32, color: u32) Instance { 57 pub fn solid(x: f32, y: f32, w: f32, h: f32, color: u32) Instance {
58 return .{ .x = x, .y = y, .w = w, .h = h, .u0 = 0, .v0 = 0, .u1 = 0, .v1 = 0, .rgba = color, .kind = Instance.solid }; 58 return .{ .x = x, .y = y, .w = w, .h = h, .u0 = 0, .v0 = 0, .u1 = 0, .v1 = 0, .rgba = color, .kind = Instance.solid };
59 } 59 }
60 pub fn variantOf(flags: u16) atlas.Variant { 60 pub fn variantOf(flags: u16) atlas.Variant {
@@ -241,3 +241,51 @@ test "glyphs and a clipped wide edge stay inside the authoritative span" {
241 try std.testing.expect(glyph.u0 > @as(f32, 2) / ctx.atlas_w); 241 try std.testing.expect(glyph.u0 > @as(f32, 2) / ctx.atlas_w);
242 try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); 242 try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w);
243 } 243 }
244
245 /// Clip only the newly emitted range, adjusting glyph UVs proportionally.
246 /// Removed instances are compacted without changing background/foreground order.
247 pub fn clip(list: *std.ArrayListUnmanaged(Instance), start: usize, x: f32, y: f32, w: f32, h: f32) void {
248 var out = start;
249 for (list.items[start..]) |old| {
250 const left = @max(x, old.x);
251 const top = @max(y, old.y);
252 const right = @min(x + w, old.x + old.w);
253 const bottom = @min(y + h, old.y + old.h);
254 if (right <= left or bottom <= top) continue;
255 var v = old;
256 v.x = left;
257 v.y = top;
258 v.w = right - left;
259 v.h = bottom - top;
260 v.u0 = old.u0 + (old.u1 - old.u0) * (left - old.x) / old.w;
261 v.u1 = old.u0 + (old.u1 - old.u0) * (right - old.x) / old.w;
262 v.v0 = old.v0 + (old.v1 - old.v0) * (top - old.y) / old.h;
263 v.v1 = old.v0 + (old.v1 - old.v0) * (bottom - old.y) / old.h;
264 list.items[out] = v;
265 out += 1;
266 }
267 list.items.len = out;
268 }
269
270 test "pane clipping preserves adjacent stream and crops glyph UVs on both axes" {
271 const a = std.testing.allocator;
272 var list: std.ArrayListUnmanaged(Instance) = .empty;
273 defer list.deinit(a);
274 try list.append(a, solid(0, 0, 5, 5, 0));
275 var glyph = solid(10, 10, 20, 20, 0);
276 glyph.kind = Instance.glyph;
277 glyph.u1 = 1;
278 glyph.v1 = 1;
279 try list.append(a, glyph);
280 try list.append(a, solid(100, 100, 5, 5, 0));
281 clip(&list, 1, 15, 20, 10, 5);
282 try std.testing.expectEqual(@as(usize, 2), list.items.len);
283 try std.testing.expectEqual(@as(f32, 0), list.items[0].x);
284 const v = list.items[1];
285 try std.testing.expectEqual(@as(f32, 15), v.x);
286 try std.testing.expectEqual(@as(f32, 5), v.h);
287 try std.testing.expectEqual(@as(f32, 0.25), v.u0);
288 try std.testing.expectEqual(@as(f32, 0.75), v.u1);
289 try std.testing.expectEqual(@as(f32, 0.5), v.v0);
290 try std.testing.expectEqual(@as(f32, 0.75), v.v1);
291 }
src/gui/runtime.zig
Old New
@@ -0,0 +1,156 @@
1 //! Pane attachments and reusable owned frame snapshots. Transport threads
2 //! never touch workspace geometry; notifications carry stable attachment keys.
3 const std = @import("std");
4 const client = @import("client");
5 const term = @import("term");
6 const model = @import("workspace.zig");
7 const Pump = client.session_pump.Pump;
8 pub const Notify = struct { ctx: ?*anyopaque = null, call: ?*const fn (?*anyopaque, model.Attachment) void = null };
9 pub const Live = struct {
10 key: model.Attachment,
11 pump: *Pump,
12 snapshot: *term.grid.Grid,
13 snapshot_seq: u64 = 0,
14 painted_seq: u64 = 0,
15 status: client.session_pump.State = .{},
16 size: term.protocol.Size,
17 notify: Notify,
18 pending: std.atomic.Value(bool) = .init(false),
19 bell_until: i64 = 0,
20 fn wake(ctx: ?*anyopaque) void {
21 const self: *Live = @ptrCast(@alignCast(ctx.?));
22 if (self.pending.swap(true, .acq_rel)) return;
23 if (self.notify.call) |f| f(self.notify.ctx, self.key);
24 }
25 pub fn capture(self: *Live, cols: u16, rows: u16) !u32 {
26 self.pump.mu.lock();
27 defer self.pump.mu.unlock();
28 const src = self.pump.grid;
29 try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows));
30 self.snapshot_seq = self.pump.replica.last_seq;
31 return self.pump.last_apply_us;
32 }
33 };
34 pub fn copyGrid(dst: *term.grid.Grid, src: *const term.grid.Grid, cols: u16, rows: u16) !void {
35 if (dst.cols != cols or dst.rows != rows) try dst.resize(cols, rows);
36 for (dst.lines, 0..) |*row, y| {
37 const from = src.row(@intCast(y));
38 // Offsets are copied with their text arena; no borrowed source text
39 // survives the pump mutex, even if another frame arrives immediately.
40 try row.text.resize(dst.alloc, from.text.items.len);
41 @memcpy(row.text.items, from.text.items);
42 @memcpy(row.cells, from.cells[0..cols]);
43 }
44 dst.cursor = src.cursor;
45 }
46 pub const Runtime = struct {
47 alloc: std.mem.Allocator,
48 workspace: model.Workspace,
49 lives: [model.max_panes]?*Live = @splat(null),
50 notify: Notify,
51 pub fn init(alloc: std.mem.Allocator, notify: Notify) Runtime {
52 return .{ .alloc = alloc, .workspace = model.Workspace.init(alloc), .notify = notify };
53 }
54 pub fn deinit(self: *Runtime) void {
55 for (self.lives) |p| if (p) |live| {
56 live.pump.say(.quit) catch unreachable;
57 };
58 for (self.lives) |p| if (p) |live| {
59 live.pump.stop();
60 live.snapshot.deinit();
61 self.alloc.destroy(live);
62 };
63 self.workspace.deinit();
64 }
65 pub fn get(self: *Runtime, id: model.PaneId) ?*Live {
66 for (self.lives) |p| if (p) |live| {
67 if (live.key.pane == id) return live;
68 };
69 return null;
70 }
71 pub fn accepts(self: *Runtime, key: model.Attachment) bool {
72 const live = self.get(key.pane) orelse return false;
73 return live.key.generation == key.generation;
74 }
75 pub fn add(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics) !model.PaneId {
76 var prepared = try self.workspace.prepare(target, session, width, height, metrics);
77 errdefer prepared.discard(self.alloc);
78 const live = try self.alloc.create(Live);
79 errdefer self.alloc.destroy(live);
80 const grid = try term.grid.Grid.init(self.alloc, 1, 1);
81 errdefer grid.deinit();
82 live.* = .{ .key = .{ .pane = prepared.pane.id, .generation = prepared.pane.generation }, .pump = undefined, .snapshot = grid, .size = .{ .cols = prepared.placement.cols, .rows = prepared.placement.rows }, .notify = self.notify };
83 live.pump = try Pump.start(self.alloc, .{ .target = prepared.pane.identity.target, .session = prepared.pane.identity.session, .cols = live.size.cols, .rows = live.size.rows, .wake = Live.wake, .wake_ctx = live });
84 for (&self.lives) |*slot| if (slot.* == null) {
85 slot.* = live;
86 break;
87 };
88 self.workspace.commit(prepared);
89 return live.key.pane;
90 }
91 pub fn remove(self: *Runtime, id: model.PaneId) void {
92 for (&self.lives) |*slot| if (slot.*) |live| {
93 if (live.key.pane == id) {
94 live.pump.stop();
95 live.snapshot.deinit();
96 self.alloc.destroy(live);
97 slot.* = null;
98 break;
99 }
100 };
101 self.workspace.remove(id);
102 }
103 pub fn resize(self: *Runtime, layout: *const model.Layout) !void {
104 for (layout.items()) |p| {
105 const live = self.get(p.id) orelse continue;
106 const size: term.protocol.Size = .{ .cols = p.cols, .rows = p.rows };
107 if (!std.meta.eql(live.size, size)) {
108 try live.pump.say(.{ .resize = size });
109 live.size = size;
110 }
111 }
112 }
113 pub fn input(self: *Runtime, text: []const u8) !void {
114 const live = self.get(self.workspace.tab().focus orelse return) orelse return;
115 switch (live.status.phase) {
116 .dialing, .attached, .reconnecting => try live.pump.say(.{ .input = text }),
117 else => {},
118 }
119 }
120 pub fn poll(self: *Runtime, now: i64) bool {
121 var changed = false;
122 for (self.lives) |p| if (p) |live| {
123 changed = live.pending.swap(false, .acq_rel) or changed;
124 const status = live.pump.state();
125 if (status.phase != live.status.phase or status.exit_code != live.status.exit_code) changed = true;
126 live.status = status;
127 if (status.bell) {
128 live.bell_until = now + 200;
129 changed = true;
130 }
131 if (live.bell_until != 0 and now >= live.bell_until) {
132 live.bell_until = 0;
133 changed = true;
134 }
135 };
136 return changed;
137 }
138 };
139
140 test "frozen pane grids own text and survive live mutation and shrink" {
141 const a = std.testing.allocator;
142 const src = try term.grid.Grid.init(a, 4, 2);
143 defer src.deinit();
144 const dst = try term.grid.Grid.init(a, 1, 1);
145 defer dst.deinit();
146 try src.lines[1].text.appendSlice(a, "old");
147 src.lines[1].cells[2] = .{ .text_len = 3 };
148 try copyGrid(dst, src, 4, 2);
149 @memcpy(src.lines[1].text.items, "new");
150 src.clear();
151 try std.testing.expectEqualStrings("old", dst.row(1).textOf(dst.row(1).cells[2]));
152 try copyGrid(dst, src, 2, 1);
153 try std.testing.expectEqual(@as(u16, 2), dst.cols);
154 try std.testing.expectEqual(@as(u16, 1), dst.rows);
155 try std.testing.expectEqual(@as(usize, 0), dst.row(0).text.items.len);
156 }
src/gui/workspace.zig
Old New
@@ -0,0 +1,448 @@
1 //! Native workspace ownership and pixel geometry. One tab today; pane IDs
2 //! and attachment generations never depend on array slots or current focus.
3 //! A bounded binary tree keeps leaf insertion transactional without importing
4 //! the terminal wall's cell rails, allocation paths, or insertion policy.
5 const std = @import("std");
6 const client = @import("client");
7
8 pub const PaneId = u64;
9 pub const TabId = u64;
10 pub const max_panes = 32;
11 pub const Direction = enum { beside, stacked };
12 pub const Neighbor = enum { left, down, up, right };
13 pub const Attachment = struct { pane: PaneId, generation: u64 };
14 pub const Rect = struct {
15 x: u32 = 0,
16 y: u32 = 0,
17 w: u32 = 0,
18 h: u32 = 0,
19 pub fn intersect(a: Rect, b: Rect) Rect {
20 const x = @max(a.x, b.x);
21 const y = @max(a.y, b.y);
22 return .{ .x = x, .y = y, .w = @min(a.x + a.w, b.x + b.w) -| x, .h = @min(a.y + a.h, b.y + b.h) -| y };
23 }
24 pub fn contains(r: Rect, x: u32, y: u32) bool {
25 return x >= r.x and y >= r.y and x - r.x < r.w and y - r.y < r.h;
26 }
27 };
28 pub const Metrics = struct {
29 cell_w: u16,
30 cell_h: u16,
31 divider: u16 = 1,
32 fn minimum(self: Metrics) Rect {
33 std.debug.assert(self.cell_w > 0 and self.cell_h > 0);
34 return .{ .w = @as(u32, self.cell_w) * 2, .h = @as(u32, self.cell_h) * 2 };
35 }
36 };
37 pub const Placement = struct { id: PaneId, outer: Rect, header: Rect, content: Rect, visible: Rect, cols: u16, rows: u16 };
38 pub const Layout = struct {
39 panes: [max_panes]Placement = undefined,
40 len: usize = 0,
41 pub fn items(self: *const Layout) []const Placement {
42 return self.panes[0..self.len];
43 }
44 pub fn get(self: *const Layout, id: PaneId) ?Placement {
45 for (self.items()) |p| if (p.id == id) return p;
46 return null;
47 }
48 pub fn hit(self: *const Layout, x: u32, y: u32) ?PaneId {
49 for (self.items()) |p| if (p.visible.contains(x, y)) return p.id;
50 return null;
51 }
52 };
53
54 pub const Identity = struct {
55 arena: std.heap.ArenaAllocator,
56 target: client.Target,
57 session: []const u8,
58 label: []const u8,
59 pub fn init(alloc: std.mem.Allocator, target: client.Target, session: []const u8) !Identity {
60 if (session.len > 0 and !@import("term").protocol.validSessionName(session)) return error.InvalidSession;
61 var arena = std.heap.ArenaAllocator.init(alloc);
62 errdefer arena.deinit();
63 const a = arena.allocator();
64 const owned = try cloneTarget(a, target);
65 const name = try a.dupe(u8, @import("term").protocol.resolveName(session));
66 const host = switch (owned) {
67 .sock => |s| s,
68 .via => |s| s,
69 .quic => |q| q.host_port,
70 .hand => |h| h.host,
71 };
72 const label = try std.fmt.allocPrint(a, "{s}#{s}", .{ host, name });
73 return .{ .arena = arena, .target = owned, .session = name, .label = label };
74 }
75 pub fn deinit(self: *Identity) void {
76 self.arena.deinit();
77 }
78 };
79 fn cloneTarget(a: std.mem.Allocator, target: client.Target) !client.Target {
80 return switch (target) {
81 .sock => |s| .{ .sock = try a.dupe(u8, s) },
82 .via => |s| .{ .via = try a.dupe(u8, s) },
83 .quic => |q| blk: {
84 var copy = q;
85 copy.host_port = try a.dupe(u8, q.host_port);
86 copy.key_path = try a.dupe(u8, q.key_path);
87 break :blk .{ .quic = copy };
88 },
89 .hand => |h| blk: {
90 var copy = h;
91 copy.host = try a.dupe(u8, h.host);
92 copy.ssh_argv = try cloneArgv(a, h.ssh_argv);
93 copy.asked_argv = try cloneArgv(a, h.asked_argv);
94 if (h.cache_path) |s| copy.cache_path = try a.dupe(u8, s);
95 if (h.ask_sock) |s| copy.ask_sock = try a.dupe(u8, s);
96 copy.ask_exe = try a.dupe(u8, h.ask_exe);
97 break :blk .{ .hand = copy };
98 },
99 };
100 }
101 fn cloneArgv(a: std.mem.Allocator, args: []const []const u8) ![]const []const u8 {
102 const out = try a.alloc([]const u8, args.len);
103 for (args, out) |s, *d| d.* = try a.dupe(u8, s);
104 return out;
105 }
106 pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity };
107 const Node = union(enum) { empty, leaf: PaneId, split: struct { direction: Direction, a: u8, b: u8 } };
108 const Tree = struct {
109 nodes: [max_panes * 2 - 1]Node = @splat(.empty),
110 root: ?u8 = null,
111 fn free(self: *Tree) !u8 {
112 for (self.nodes, 0..) |n, i| if (n == .empty) return @intCast(i);
113 return error.WorkspaceFull;
114 }
115 fn leaf(self: *const Tree, id: PaneId) ?u8 {
116 for (self.nodes, 0..) |n, i| if (n == .leaf and n.leaf == id) return @intCast(i);
117 return null;
118 }
119 fn insert(self: *Tree, origin: ?PaneId, id: PaneId, direction: Direction) !void {
120 if (self.root == null) {
121 const at = try self.free();
122 self.nodes[at] = .{ .leaf = id };
123 self.root = at;
124 return;
125 }
126 const at = self.leaf(origin orelse return error.MissingPane) orelse return error.MissingPane;
127 const a = try self.free();
128 self.nodes[a] = self.nodes[at];
129 const b = try self.free();
130 self.nodes[b] = .{ .leaf = id };
131 self.nodes[at] = .{ .split = .{ .direction = direction, .a = a, .b = b } };
132 }
133 fn remove(self: *Tree, id: PaneId) void {
134 const at = self.leaf(id) orelse return;
135 if (self.root == at) {
136 self.nodes[at] = .empty;
137 self.root = null;
138 return;
139 }
140 for (&self.nodes) |*node| {
141 if (node.* != .split) continue;
142 const s = node.split;
143 if (s.a == at or s.b == at) {
144 const sibling = if (s.a == at) s.b else s.a;
145 node.* = self.nodes[sibling];
146 self.nodes[sibling] = .empty;
147 self.nodes[at] = .empty;
148 return;
149 }
150 }
151 }
152 fn minimum(self: *const Tree, at: u8, m: Metrics) Rect {
153 return switch (self.nodes[at]) {
154 .leaf => m.minimum(),
155 .split => |s| blk: {
156 const a = self.minimum(s.a, m);
157 const b = self.minimum(s.b, m);
158 break :blk switch (s.direction) {
159 .beside => .{ .w = a.w + b.w + m.divider, .h = @max(a.h, b.h) },
160 .stacked => .{ .w = @max(a.w, b.w), .h = a.h + b.h + m.divider },
161 };
162 },
163 .empty => unreachable,
164 };
165 }
166 fn flatten(self: *const Tree, at: u8, rect: Rect, viewport: Rect, m: Metrics, out: *Layout) void {
167 switch (self.nodes[at]) {
168 .leaf => |id| {
169 const content: Rect = .{ .x = rect.x, .y = rect.y + m.cell_h, .w = rect.w, .h = rect.h - m.cell_h };
170 out.panes[out.len] = .{ .id = id, .outer = rect, .header = .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = m.cell_h }, .content = content, .visible = rect.intersect(viewport), .cols = @intCast(@min(@max(content.w / m.cell_w, 1), @import("term").protocol.max_cols)), .rows = @intCast(@min(@max(content.h / m.cell_h, 1), std.math.maxInt(u16))) };
171 out.len += 1;
172 },
173 .split => |s| {
174 const amin = self.minimum(s.a, m);
175 const bmin = self.minimum(s.b, m);
176 var a = rect;
177 var b = rect;
178 switch (s.direction) {
179 .beside => {
180 const space = rect.w - m.divider;
181 a.w = std.math.clamp((space + 1) / 2, amin.w, space - bmin.w);
182 b.x += a.w + m.divider;
183 b.w = space - a.w;
184 },
185 .stacked => {
186 const space = rect.h - m.divider;
187 a.h = std.math.clamp((space + 1) / 2, amin.h, space - bmin.h);
188 b.y += a.h + m.divider;
189 b.h = space - a.h;
190 },
191 }
192 self.flatten(s.a, a, viewport, m, out);
193 self.flatten(s.b, b, viewport, m, out);
194 },
195 .empty => unreachable,
196 }
197 }
198 };
199 pub const Pending = struct { pane: PaneId, direction: Direction };
200 pub const Tab = struct { id: TabId = 1, tree: Tree = .{}, focus: ?PaneId = null, pending: ?Pending = null, panes: [max_panes]?*Pane = @splat(null) };
201 pub const Prepared = struct {
202 pane: *Pane,
203 tree: Tree,
204 placement: Placement,
205 pub fn discard(self: *Prepared, alloc: std.mem.Allocator) void {
206 self.pane.identity.deinit();
207 alloc.destroy(self.pane);
208 }
209 };
210 pub const Workspace = struct {
211 alloc: std.mem.Allocator,
212 tabs: [1]Tab = .{.{}},
213 active_tab_id: TabId = 1,
214 next_pane_id: PaneId = 1,
215 pub fn init(alloc: std.mem.Allocator) Workspace {
216 return .{ .alloc = alloc };
217 }
218 pub fn tab(self: *Workspace) *Tab {
219 return &self.tabs[0];
220 }
221 pub fn deinit(self: *Workspace) void {
222 for (self.tab().panes) |p| if (p) |v| {
223 v.identity.deinit();
224 self.alloc.destroy(v);
225 };
226 }
227 pub fn pane(self: *Workspace, id: PaneId) ?*Pane {
228 for (self.tab().panes) |p| if (p) |v| {
229 if (v.id == id) return v;
230 };
231 return null;
232 }
233 pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout {
234 var out: Layout = .{};
235 if (self.tab().tree.root) |root| {
236 const min = self.tab().tree.minimum(root, m);
237 self.tab().tree.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out);
238 }
239 return out;
240 }
241 pub fn arm(self: *Workspace, direction: Direction) void {
242 if (self.tab().focus) |id| self.tab().pending = .{ .pane = id, .direction = direction };
243 }
244 pub fn cancel(self: *Workspace) void {
245 self.tab().pending = null;
246 }
247 pub fn focus(self: *Workspace, id: PaneId) bool {
248 if (self.pane(id) == null) return false;
249 self.tab().focus = id;
250 return true;
251 }
252 pub fn prepare(self: *Workspace, target: client.Target, session: []const u8, width: u32, height: u32, m: Metrics) !Prepared {
253 const t = self.tab();
254 var count: usize = 0;
255 for (t.panes) |p| {
256 if (p != null) count += 1;
257 }
258 if (count == max_panes) return error.WorkspaceFull;
259 const origin = if (t.pending) |p| p.pane else t.focus;
260 const direction = if (t.pending) |p| p.direction else Direction.beside;
261 if (t.tree.root) |r| {
262 const min = t.tree.minimum(r, m);
263 if (min.w > width or min.h > height) return error.TooSmall;
264 const flat = self.layout(width, height, m);
265 const old = flat.get(origin orelse return error.MissingPane) orelse return error.MissingPane;
266 const leaf_min = m.minimum();
267 if ((direction == .beside and old.outer.w < leaf_min.w * 2 + m.divider) or (direction == .stacked and old.outer.h < leaf_min.h * 2 + m.divider)) return error.TooSmall;
268 }
269 var tree = t.tree;
270 const id = self.next_pane_id;
271 try tree.insert(origin, id, direction);
272 const p = try self.alloc.create(Pane);
273 errdefer self.alloc.destroy(p);
274 p.* = .{ .id = id, .identity = try Identity.init(self.alloc, target, session) };
275 self.next_pane_id += 1;
276 var flat: Layout = .{};
277 const min = tree.minimum(tree.root.?, m);
278 tree.flatten(tree.root.?, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &flat);
279 return .{ .pane = p, .tree = tree, .placement = flat.get(id).? };
280 }
281 /// Call immediately after preparing the attachment; no other model edits
282 /// may occur between prepare and commit. This operation cannot allocate.
283 pub fn commit(self: *Workspace, prepared: Prepared) void {
284 const t = self.tab();
285 for (&t.panes) |*p| if (p.* == null) {
286 p.* = prepared.pane;
287 break;
288 };
289 t.tree = prepared.tree;
290 t.focus = prepared.pane.id;
291 t.pending = null;
292 }
293 /// The caller has already joined this pane's pump and discarded its wake
294 /// context. Removing a pending origin cancels that pending insertion.
295 pub fn remove(self: *Workspace, id: PaneId) void {
296 const t = self.tab();
297 for (&t.panes) |*p| if (p.*) |v| {
298 if (v.id == id) {
299 v.identity.deinit();
300 self.alloc.destroy(v);
301 p.* = null;
302 break;
303 }
304 };
305 t.tree.remove(id);
306 if (t.pending) |p| if (p.pane == id) {
307 t.pending = null;
308 };
309 if (t.focus == id) {
310 t.focus = null;
311 for (t.panes) |p| if (p) |v| {
312 t.focus = v.id;
313 break;
314 };
315 }
316 }
317 pub fn moveFocus(self: *Workspace, flat: *const Layout, direction: Neighbor) void {
318 const from = flat.get(self.tab().focus orelse return) orelse return;
319 var best: ?PaneId = null;
320 var score: u64 = std.math.maxInt(u64);
321 for (flat.items()) |p| {
322 if (p.id == from.id) continue;
323 const f = from.outer;
324 const r = p.outer;
325 const valid = switch (direction) {
326 .left => r.x + r.w <= f.x,
327 .right => r.x >= f.x + f.w,
328 .up => r.y + r.h <= f.y,
329 .down => r.y >= f.y + f.h,
330 };
331 if (!valid) continue;
332 const main: u32 = switch (direction) {
333 .left => f.x - (r.x + r.w),
334 .right => r.x - (f.x + f.w),
335 .up => f.y - (r.y + r.h),
336 .down => r.y - (f.y + f.h),
337 };
338 const mid = switch (direction) {
339 .left, .right => f.y + f.h / 2,
340 .up, .down => f.x + f.w / 2,
341 };
342 const lo = switch (direction) {
343 .left, .right => r.y,
344 .up, .down => r.x,
345 };
346 const hi = lo + switch (direction) {
347 .left, .right => r.h,
348 .up, .down => r.w,
349 };
350 const perp = if (mid < lo) lo - mid else mid -| hi;
351 const value = @as(u64, main) * 1_000_000 + perp;
352 if (value < score) {
353 score = value;
354 best = p.id;
355 }
356 }
357 if (best) |id| _ = self.focus(id);
358 }
359 };
360
361 fn add(w: *Workspace, width: u32, height: u32) !PaneId {
362 const p = try w.prepare(.{ .via = "cat" }, "test", width, height, .{ .cell_w = 10, .cell_h = 20 });
363 w.commit(p);
364 return p.pane.id;
365 }
366 test "workspace nested insertion focus cancellation removal and tiny clipping preserve IDs" {
367 var w = Workspace.init(std.testing.allocator);
368 defer w.deinit();
369 const a = try add(&w, 800, 600);
370 w.arm(.beside);
371 const unchanged = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
372 w.cancel();
373 try std.testing.expectEqualDeep(unchanged.items(), w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 }).items());
374 w.arm(.beside);
375 const b = try add(&w, 800, 600);
376 var flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
377 const left = flat.get(a).?.outer;
378 w.arm(.stacked);
379 _ = w.focus(a);
380 const c = try add(&w, 800, 600);
381 flat = w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 });
382 try std.testing.expectEqual(left, flat.get(a).?.outer);
383 try std.testing.expect(flat.get(c).?.outer.y > flat.get(b).?.outer.y);
384 w.moveFocus(&flat, .up);
385 try std.testing.expectEqual(b, w.tab().focus.?);
386 w.moveFocus(&flat, .left);
387 try std.testing.expectEqual(a, w.tab().focus.?);
388 const tiny = w.layout(5, 5, .{ .cell_w = 10, .cell_h = 20 });
389 try std.testing.expectEqual(@as(usize, 3), tiny.len);
390 try std.testing.expect(tiny.hit(6, 2) == null);
391 for (tiny.items()) |p| {
392 try std.testing.expect(p.cols > 0 and p.rows > 0);
393 }
394 try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 5, 5, .{ .cell_w = 10, .cell_h = 20 }));
395 w.arm(.beside);
396 w.remove(a);
397 try std.testing.expect(w.tab().pending == null);
398 const d = try add(&w, 800, 600);
399 try std.testing.expect(d > c);
400 }
401 test "workspace refuses a nested leaf split even if global minima would fit" {
402 var w = Workspace.init(std.testing.allocator);
403 defer w.deinit();
404 const a = try add(&w, 100, 100);
405 _ = try add(&w, 100, 100);
406 _ = w.focus(a);
407 _ = try add(&w, 100, 100);
408 // Four leaves need83px globally, but the armed25px leaf needs41px.
409 try std.testing.expectError(error.TooSmall, w.prepare(.{ .via = "cat" }, "x", 100, 100, .{ .cell_w = 10, .cell_h = 20 }));
410 }
411 fn allocationCase(alloc: std.mem.Allocator) !void {
412 var w = Workspace.init(alloc);
413 defer w.deinit();
414 _ = try add(&w, 800, 600);
415 w.arm(.stacked);
416 const before = w.tab().focus;
417 const p = w.prepare(.{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = "/tmp/cache" } }, "other", 800, 600, .{ .cell_w = 10, .cell_h = 20 }) catch |err| {
418 try std.testing.expectEqual(before, w.tab().focus);
419 try std.testing.expectEqual(@as(usize, 1), w.layout(800, 600, .{ .cell_w = 10, .cell_h = 20 }).len);
420 return err;
421 };
422 w.commit(p);
423 }
424 test "workspace insertion allocation failures leave no half insertion or identity leak" {
425 try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationCase, .{});
426 }
427
428 test "workspace identity owns nested target arguments beyond caller buffers" {
429 var host = [_]u8{ 'h', 'o', 's', 't' };
430 var key = [_]u8{ '/', 'k', 'e', 'y' };
431 var name = [_]u8{ 'w', 'o', 'r', 'k' };
432 var hand = try Identity.init(std.testing.allocator, .{ .hand = .{ .host = &host, .ssh_argv = &.{ "ssh", &host }, .asked_argv = &.{ "ssh", &host, "start" }, .cache_path = &key, .ask_sock = &key, .ask_exe = &key } }, &name);
433 defer hand.deinit();
434 var quic = try Identity.init(std.testing.allocator, .{ .quic = .{ .host_port = &host, .key_path = &key } }, &name);
435 defer quic.deinit();
436 @memset(&host, 'x');
437 @memset(&key, 'x');
438 @memset(&name, 'x');
439 try std.testing.expectEqualStrings("host", hand.target.hand.ssh_argv[1]);
440 try std.testing.expectEqualStrings("host", hand.target.hand.asked_argv[1]);
441 try std.testing.expectEqualStrings("/key", hand.target.hand.cache_path.?);
442 try std.testing.expectEqualStrings("/key", hand.target.hand.ask_sock.?);
443 try std.testing.expectEqualStrings("/key", hand.target.hand.ask_exe);
444 try std.testing.expectEqualStrings("host", quic.target.quic.host_port);
445 try std.testing.expectEqualStrings("/key", quic.target.quic.key_path);
446 try std.testing.expectEqualStrings("work", hand.session);
447 try std.testing.expectEqualStrings("host#work", hand.label);
448 }
test/e2e_08_mouse.sh
Old New
@@ -147,7 +147,7 @@ ok "the wheel scrolls back and returns to live, and never reaches the pty"
147 # script asks for it on top of vim's `set mouse=a` set. 147 # script asks for it on top of vim's `set mouse=a` set.
148 cat > "$MOUSESH" <<'EOF' 148 cat > "$MOUSESH" <<'EOF'
149 #!/bin/sh 149 #!/bin/sh
150 seq 1 100 150 seq 1 100 | sed 's/^/app-history-row-/'
151 printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h' 151 printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'
152 printf 'app-holds-the-mouse\n' 152 printf 'app-holds-the-mouse\n'
153 exec /bin/cat 153 exec /bin/cat
@@ -186,7 +186,8 @@ grep -qaF "$(printf '\033[?1003h')" "$OUT.mse" || {
186 echo "e2e FAIL: app mouse: the client never mirrored the session's any-motion mode"; exit 1; } 186 echo "e2e FAIL: app mouse: the client never mirrored the session's any-motion mode"; exit 1; }
187 # And the negative that makes the pair a pair: this session has the same 187 # And the negative that makes the pair a pair: this session has the same
188 # 77 rows of history as (a), and the same wheel byte moved none of it. 188 # 77 rows of history as (a), and the same wheel byte moved none of it.
189 grep -qF -- "60" "$OUT.mse" && { 189 # A bare number also matches the PID in the pane's socket label.
190 grep -qF -- "app-history-row-60" "$OUT.mse" && {
190 echo "e2e FAIL: app mouse: the client scrolled back on a wheel the app owned"; exit 1; } 191 echo "e2e FAIL: app mouse: the client scrolled back on a wheel the app owned"; exit 1; }
191 assert_stopped "$SOCK34" "$D31PID" "app mouse" "$OUT.msestop" 192 assert_stopped "$SOCK34" "$D31PID" "app mouse" "$OUT.msestop"
192 D31PID="" 193 D31PID=""
test/native.sh
Old New
@@ -160,10 +160,15 @@ EXITPID=$!
160 defer_kill "$EXITPID" 160 defer_kill "$EXITPID"
161 wait_until 100 "named native session attached" '[ "$(attaches_now "$SOCK")" -gt "$attaches_before" ]' 161 wait_until 100 "named native session attached" '[ "$(attaches_now "$SOCK")" -gt "$attaches_before" ]'
162 printf '%s\n' 'text:exit 7' 'key:enter' >&8 162 printf '%s\n' 'text:exit 7' 'key:enter' >&8
163 wait_pid_gone "$EXITPID" "native window follows shell exit" 163 exited_state() {
164 exit_rc=0 164 [ -s "$1" ] || return 1
165 wait "$EXITPID" || exit_rc=$? 165 python3 -c 'import json,sys; p=json.load(open(sys.argv[1]))["panes"][0]; sys.exit(not (p["phase"] == "exited" and p["exit_code"] == 7))' "$1"
166 [ "$exit_rc" -eq 7 ] || { echo "native FAIL: shell exit 7 became $exit_rc"; cat "$OUT.native-exit.log"; exit 1; } 166 }
167 ok "a named native session returns the shell's exit code" 167 wait_until 50 "native pane retains shell exit 7" 'printf "state:%s\n" "$OUT.native-exit.json" >&8; exited_state "$OUT.native-exit.json"'
168 kill -0 "$EXITPID" || { echo "native FAIL: one exited pane closed the window"; exit 1; }
169 printf 'quit\n' >&8
170 wait_pid_gone "$EXITPID" "native window exits after explicit quit"
171 wait "$EXITPID" || { echo "native FAIL: explicit quit did not detach cleanly"; exit 1; }
172 ok "a named native session retains its exit code in an open pane until explicit quit"
168 173
169 echo "native OK ($OK_COUNT checkpoints)" 174 echo "native OK ($OK_COUNT checkpoints)"
test/native_tiling.py
Old New
@@ -0,0 +1,418 @@
1 #!/usr/bin/env python3
2 """Two independent daemons through the native client's real SDL event paths.
3
4 The state hook describes geometry; daemon grids, PTY dimensions, and framebuffer
5 pixels independently establish that input and rendering use that geometry.
6 """
7 import json
8 import os
9 from pathlib import Path
10 import re
11 import shlex
12 import signal
13 import socket
14 import subprocess
15 import sys
16 import tempfile
17 import time
18
19
20 DEADLINE = 5.0
21
22
23 def require(condition, message):
24 if not condition:
25 raise AssertionError(message)
26
27
28 def eventually(probe, message, seconds=DEADLINE):
29 deadline = time.monotonic() + seconds
30 while time.monotonic() < deadline:
31 result = probe()
32 if result:
33 return result
34 time.sleep(0.04)
35 raise AssertionError(message)
36
37
38 class Rig:
39 def __init__(self, mux, muxg):
40 self.mux, self.muxg = str(Path(mux).resolve()), str(Path(muxg).resolve())
41 self.root = Path(tempfile.mkdtemp(prefix="muxg-tiling-"))
42 self.env = os.environ.copy()
43 # Preserve the compositor address before isolating runtime state.
44 self.env["WAYLAND_DISPLAY"] = str(Path(self.env.get("XDG_RUNTIME_DIR", "/tmp")) /
45 self.env.get("WAYLAND_DISPLAY", "wayland-0"))
46 for key in ("XDG_STATE_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
47 path = self.root / key
48 path.mkdir(mode=0o700)
49 self.env[key] = str(path)
50 self.env["SHELL"] = "/bin/sh"
51 self.env["SDL_VIDEO_DRIVER"] = os.environ.get("MUXG_VIDEODRIVER", "offscreen")
52 self.env.pop("SDL_VIDEODRIVER", None)
53 self.procs, self.logs, self.daemons = [], [], []
54 self.targets = {}
55 self.gui = None
56 self.fd = None
57 self.serial = 0
58 self.checkpoints = 0
59
60 def spawn(self, argv, label, env=None):
61 log = (self.root / (label + ".log")).open("wb")
62 self.logs.append(log)
63 proc = subprocess.Popen(argv, env=self.env if env is None else env,
64 stdout=log, stderr=subprocess.STDOUT)
65 self.procs.append(proc)
66 return proc
67
68 def command(self, *args, check=True):
69 return subprocess.run([self.mux, *args], env=self.env, capture_output=True,
70 text=True, timeout=3, check=check)
71
72 def stop_daemon(self, sock, proc):
73 # `mux d stop` waits for its peer PID to disappear. Reap our child while
74 # that command waits, otherwise its zombie keeps the PID alive forever.
75 stop = self.spawn([self.mux, "d", "stop", "--sock", sock], f"stop-{proc.pid}")
76 def reaped():
77 daemon_done = proc.poll() is not None
78 stop_done = stop.poll() is not None
79 return daemon_done and stop_done
80 eventually(reaped, "daemon did not stop and get reaped")
81 require(stop.returncode == 0, "daemon stop command failed")
82
83 def daemon(self, label, quic=False):
84 sock = str(self.root / (label + ".sock"))
85 env = self.env.copy()
86 home = self.root / (label + "-state")
87 home.mkdir()
88 for key in ("XDG_STATE_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME"):
89 path = home / key
90 path.mkdir(mode=0o700)
91 env[key] = str(path)
92 args = [self.mux, "d", "start", "--sock", sock]
93 if quic:
94 self.command("d", "keygen")
95 with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as reserve:
96 reserve.bind(("127.0.0.1", 0))
97 port = reserve.getsockname()[1]
98 args += ["--quic", f"127.0.0.1:{port}", "--key",
99 str(Path(self.env["XDG_CONFIG_HOME"]) / "mux/key")]
100 self.targets[sock] = f"quic://127.0.0.1:{port}"
101 proc = self.spawn(args, label, env)
102 self.daemons.append((sock, proc))
103 eventually(lambda: Path(sock).exists(), label + " daemon did not start")
104 return sock, proc
105
106 def start_gui(self, first, second, label):
107 fifo = self.root / (label + ".fifo")
108 os.mkfifo(fifo)
109 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
110 self.env["MUXG_TEST_FIFO"] = str(fifo)
111 self.gui_log = self.root / (label + ".log")
112 self.gui = self.spawn([self.muxg, "--sock", first, "--session", "left",
113 "--next-target", self.targets.get(second, "--sock " + second),
114 "--next-session", "right"], label)
115 return self.wait_state(lambda s: len(s["panes"]) == 1 and
116 s["panes"][0]["phase"] == "attached")
117
118 def send(self, *lines):
119 require(self.gui.poll() is None, "GUI exited unexpectedly")
120 data = ("\n".join(lines) + "\n").encode()
121 require(os.write(self.fd, data) == len(data), "short FIFO write")
122
123 def key(self, name):
124 self.send("key:" + name)
125
126 def chord(self, key):
127 self.send("key:prefix", "key:" + key)
128
129 def shell(self, command):
130 self.send("text:" + command, "key:enter")
131
132 def artifact(self, command, suffix):
133 self.serial += 1
134 path = self.root / (str(self.serial) + suffix)
135 self.send(command + ":" + str(path))
136 eventually(lambda: path.exists() or self.gui.poll() is not None,
137 "GUI did not produce " + command)
138 require(path.exists(), "GUI exited while producing " + command)
139 return path
140
141 def state(self):
142 return json.loads(self.artifact("state", ".json").read_text())
143
144 def wait_state(self, predicate):
145 def probe():
146 state = self.state()
147 return state if predicate(state) else None
148 return eventually(probe, "GUI state did not converge")
149
150 def pixels(self):
151 raw = self.artifact("capture", ".ppm").read_bytes()
152 magic, dims, maximum, pixels = raw.split(b"\n", 3)
153 require(magic == b"P6" and maximum == b"255", "invalid framebuffer capture")
154 width, height = map(int, dims.split())
155 require(len(pixels) == width * height * 3, "truncated framebuffer capture")
156 return width, height, pixels
157
158 def dump(self, sock, session):
159 return self.command("d", "dump", "--sock", sock, "--session", session,
160 check=False).stdout
161
162 def wait_marker(self, sock, session, marker):
163 eventually(lambda: marker in self.dump(sock, session), marker + " did not reach grid")
164
165 def status(self, sock, session):
166 return json.loads(self.command("a", "status", "--sock", sock,
167 "--session", session, "--timeout", "2000").stdout)
168
169 def frames(self):
170 before = self.gui_log.read_text().count("\ntotal ")
171 self.gui.send_signal(signal.SIGUSR1)
172 def fresh():
173 text = self.gui_log.read_text()
174 return text if text.count("\ntotal ") > before else None
175 report = eventually(fresh, "no fresh completed frame report")
176 count = int(re.findall(r"timing \((\d+) frames\)", report)[-1])
177 p99 = int(re.findall(r"^total\s+\d+\s+\d+\s+(\d+)", report, re.M)[-1])
178 return count, p99
179
180 def quit(self):
181 self.send("quit")
182 require(self.gui.wait(timeout=3) == 0, "GUI failed to detach cleanly")
183 os.close(self.fd)
184 self.fd = None
185 self.gui = None
186
187 def ok(self, message):
188 self.checkpoints += 1
189 print(f"tiling OK ({self.checkpoints}): {message}", flush=True)
190
191 def close(self):
192 if self.gui is not None and self.gui.poll() is None:
193 self.gui.terminate()
194 for sock, proc in self.daemons:
195 if proc.poll() is None:
196 try:
197 self.stop_daemon(sock, proc)
198 except (subprocess.TimeoutExpired, AssertionError):
199 proc.terminate()
200 for proc in self.procs:
201 if proc.poll() is None:
202 proc.terminate()
203 try:
204 proc.wait(timeout=3)
205 except subprocess.TimeoutExpired:
206 proc.kill()
207 proc.wait(timeout=3)
208 if self.fd is not None:
209 os.close(self.fd)
210 for log in self.logs:
211 log.close()
212
213
214 def rect_contains(rect, x, y):
215 return rect["x"] <= x < rect["x"] + rect["w"] and rect["y"] <= y < rect["y"] + rect["h"]
216
217
218 def colour_counts(capture, rect, colour):
219 width, height, pixels = capture
220 inside = outside = 0
221 for offset in range(0, len(pixels), 3):
222 r, g, b = pixels[offset:offset + 3]
223 if colour == "red":
224 hit = r > 80 and r > g * 2 and r > b * 2
225 elif colour == "green":
226 hit = g > 80 and g > r * 2 and g > b * 2
227 else:
228 hit = b > 80 and b > r * 2 and b > g * 2
229 if hit:
230 y, x = divmod(offset // 3, width)
231 if rect_contains(rect, x, y):
232 inside += 1
233 else:
234 outside += 1
235 return inside, outside
236
237
238 def check_geometry(rig, state, first, second, axis):
239 a, b = state["panes"]
240 ar, br = a["outer"], b["outer"]
241 size_axis, pos_axis = ("w", "x") if axis == "v" else ("h", "y")
242 require(abs(ar[size_axis] - br[size_axis]) <= 1, "split did not divide the armed pane equally")
243 require(br[pos_axis] >= ar[pos_axis] + ar[size_axis], "panes overlap")
244 for pane, sock, session in ((a, first, "left"), (b, second, "right")):
245 content = pane["content"]
246 cols = content["w"] // state["cell_w"]
247 rows = content["h"] // state["cell_h"]
248 require(cols >= 2 and rows >= 1, "invalid terminal footprint")
249 require((pane["cols"], pane["rows"]) == (cols, rows), "GUI cell sizing differs from physical content")
250 eventually(lambda: (lambda s: (s["cols"], s["rows"]) == (cols, rows))(rig.status(sock, session)),
251 "daemon did not receive its pane's physical size")
252
253
254 def normal_scenario(rig, axis):
255 first, _ = rig.daemon("first-" + axis)
256 second, second_proc = rig.daemon("second-" + axis, quic=axis == "b")
257 if axis == "v":
258 for flags in (("--next-target", "box"), ("--next-session", "right"),
259 ("--next-target", "box", "--next-session", "bad name"),
260 ("--next-target", "box", "--next-target", "other", "--next-session", "right")):
261 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env,
262 capture_output=True, timeout=3)
263 require(result.returncode == 2, "invalid staged-target arguments were accepted")
264 rig.ok("staged target arguments reject missing partners, invalid sessions and repeated targets")
265 before = rig.start_gui(first, second, "gui-" + axis)
266 rig.chord(axis)
267 armed = rig.state()
268 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"],
269 "arming split changed geometry")
270 stats = rig.command("d", "stats", "--sock", second).stdout
271 require(re.search(r"\battaches=0\b", stats),
272 "staged target attached before insertion: " + stats)
273 # Daemons may start a default session themselves. The staged named session
274 # must still be absent, and this observer query cannot create one.
275 require(rig.command("a", "status", "--sock", second, "--session", "right",
276 "--timeout", "200", check=False).returncode != 0,
277 "staged named session exists before insertion")
278 rig.chord("escape")
279 require(len(rig.state()["panes"]) == 1, "cancel inserted a pane")
280 rig.chord(axis)
281 rig.chord("enter")
282 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
283 check_geometry(rig, state, first, second, axis)
284 transport = "Unix + QUIC" if axis == "b" else "two Unix sockets"
285 rig.ok(axis + " split over " + transport + "; both PTYs have exact pane sizes")
286
287 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mRIGHT-%s\\033[0m\\n' MARK")
288 rig.wait_marker(second, "right", "RIGHT-MARK")
289 require("RIGHT-MARK" not in rig.dump(first, "left"), "right input leaked into left pane")
290 rig.chord("h" if axis == "v" else "k")
291 rig.shell("printf '\\033[2J\\033[H\\033[31mLEFT-%s\\033[0m\\n' MARK")
292 rig.wait_marker(first, "left", "LEFT-MARK")
293 require("LEFT-MARK" not in rig.dump(second, "right"), "left input leaked into right pane")
294 state = rig.state()
295 def correct_pixels():
296 pixels = rig.pixels()
297 red = colour_counts(pixels, state["panes"][0]["content"], "red")
298 green = colour_counts(pixels, state["panes"][1]["content"], "green")
299 return red[0] > 30 and green[0] > 30 and red[1] == green[1] == 0
300 eventually(correct_pixels, "coloured glyphs missing or spill into another pane")
301 rig.ok("focus routes input independently and framebuffer regions contain only their own coloured glyphs")
302
303 rig.send("resize:640x400")
304 state = rig.wait_state(lambda s: (s["width"], s["height"]) == (640, 400))
305 check_geometry(rig, state, first, second, axis)
306 eventually(correct_pixels, "resized framebuffer lost or misplaced pane content")
307 # Clear red content after shrinking; the other pane's green text must survive.
308 rig.shell("printf '\\033[2J\\033[H'")
309 def cleared():
310 pixels = rig.pixels()
311 red = colour_counts(pixels, state["panes"][0]["content"], "red")
312 green = colour_counts(pixels, state["panes"][1]["content"], "green")
313 return sum(red) == 0 and green[0] > 30 and green[1] == 0
314 eventually(cleared, "clear/shrink left old pixels or erased a neighbour")
315 # Click uses logical coordinates; offscreen is 100%, Wayland checked separately.
316 content = state["panes"][1]["content"]
317 rig.send(f"click:{content['x'] + content['w']//2},{content['y'] + content['h']//2}")
318 rig.shell("printf 'CLICK-%s\\n' RIGHT")
319 rig.wait_marker(second, "right", "CLICK-RIGHT")
320 require("CLICK-RIGHT" not in rig.dump(first, "left"), "click focus routed to wrong pane")
321 rig.ok("window resize, neighbour-safe clearing, and mouse focus work")
322
323 if axis == "v":
324 rig.shell("stty -echo -icanon min 1 time 0; printf 'RAW-%s\\n' READY; "
325 "od -An -tx1 -N2; stty sane; printf 'RAW-%s\\n' DONE")
326 rig.wait_marker(second, "right", "RAW-READY")
327 rig.send("key:prefix", "key:prefix", "text:A")
328 rig.wait_marker(second, "right", "RAW-DONE")
329 require("00 41" in rig.dump(second, "right"), "prefix passthrough did not send one NUL then A")
330 rig.ok("double prefix sends the terminal encoding exactly once; ordinary input follows")
331 # A bounded flood with an external stop signal. During the frame-count
332 # interval only nonforcing state observations are allowed, never captures.
333 stop = rig.root / "stop-flood"
334 progress = rig.root / "flood-progress"
335 rig.chord("h")
336 flood = (f"i=0; while [ $i -lt 4096 ] && [ ! -e {shlex.quote(str(stop))} ]; do "
337 "head -c 262144 /dev/zero | tr '\\000' X; "
338 f"i=$((i+1)); echo $i > {shlex.quote(str(progress))}; done; echo FLOOD-DONE")
339 rig.shell(flood)
340 eventually(lambda: progress.exists() and progress.stat().st_size > 0, "flood did not start")
341 count1, _ = rig.frames()
342 rig.chord("l")
343 started = time.monotonic()
344 rig.shell("printf '\\033[38;2;0;0;255mRESPONSIVE-%s\\033[0m\\n' RIGHT")
345 rig.wait_marker(second, "right", "RESPONSIVE-RIGHT")
346 # State observes the last completed frame and must not itself request a
347 # repaint. This detects B staying visually stale while A keeps painting.
348 rig.wait_state(lambda s: "RESPONSIVE-RIGHT" in s["panes"][1]["painted_text"])
349 latency = time.monotonic() - started
350 time.sleep(0.3)
351 require("FLOOD-DONE" not in rig.dump(first, "left"), "flood ended before concurrent check")
352 count2, p99 = rig.frames()
353 require(colour_counts(rig.pixels(), state["panes"][1]["content"], "blue")[0] > 30,
354 "new responsive marker was not rendered in the other pane")
355 stop.touch()
356 rig.wait_marker(first, "left", "FLOOD-DONE")
357 require(count2 > count1, "no frames painted while output flooded")
358 require(latency < DEADLINE and p99 < 20000, f"flood isolation exceeded budget: {latency}s / {p99}us")
359 rig.ok(f"flood leaves other pane responsive ({latency*1000:.0f} ms input-to-frame, {p99} us frame p99)")
360
361 # Disconnect one daemon while the other continues accepting input.
362 rig.stop_daemon(second, second_proc)
363 rig.wait_state(lambda s: s["panes"][1]["phase"] != "attached")
364 rig.chord("h")
365 rig.shell("printf 'SURVIVOR-%s\\n' OK")
366 rig.wait_marker(first, "left", "SURVIVOR-OK")
367 rig.ok("a disconnected daemon cannot take down the other pane")
368 else:
369 rig.shell("exit 7")
370 rig.wait_state(lambda s: s["panes"][1]["phase"] == "exited" and s["panes"][1]["exit_code"] == 7)
371 rig.chord("k")
372 rig.shell("printf 'AFTER-EXIT-%s\\n' OK")
373 rig.wait_marker(first, "left", "AFTER-EXIT-OK")
374 rig.ok("shell exit is retained in its pane while its neighbour remains usable")
375 rig.quit()
376 require(("SURVIVOR-OK" if axis == "v" else "AFTER-EXIT-OK") in rig.dump(first, "left"),
377 "window close ended or replaced the surviving session")
378 rig.ok("closing the window preserves the surviving daemon session")
379
380
381 def unavailable_scenario(rig):
382 first, _ = rig.daemon("first-unavailable")
383 missing = str(rig.root / "missing.sock")
384 rig.start_gui(first, missing, "gui-unavailable")
385 rig.chord("v")
386 rig.chord("enter")
387 rig.wait_state(lambda s: len(s["panes"]) == 2 and s["panes"][1]["phase"] in ("dial_failed", "failed", "reconnecting"))
388 rig.chord("h")
389 rig.shell("printf 'OFFLINE-NEIGHBOUR-%s\\n' OK")
390 rig.wait_marker(first, "left", "OFFLINE-NEIGHBOUR-OK")
391 rig.send("resize:8x8")
392 state = rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8)
393 require(len(state["panes"]) == 2 and all(p["cols"] > 0 and p["rows"] > 0 for p in state["panes"]),
394 "tiny window destroyed panes or sent invalid sizes")
395 rig.quit()
396 rig.ok("initially unreachable target stays local to its pane; tiny window and shutdown remain safe")
397
398
399 def main():
400 rig = Rig(*sys.argv[1:3])
401 try:
402 version = subprocess.check_output([rig.muxg, "--version"], text=True)
403 require("ReleaseSafe" in version or "ReleaseFast" in version, "native tiling requires a release build")
404 for axis in ("v", "b"):
405 normal_scenario(rig, axis)
406 unavailable_scenario(rig)
407 print(f"native tiling OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}")
408 except BaseException:
409 print("Native tiling failure artifacts:", rig.root, file=sys.stderr)
410 for path in rig.root.glob("gui*.log"):
411 print(path.name + ":\n" + path.read_text()[-2000:], file=sys.stderr)
412 raise
413 finally:
414 rig.close()
415
416
417 if __name__ == "__main__":
418 main()