c5e30b30
feat: add independent native terminal panes
a73x 2026-09-05 06:42
Commit message
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() | ||