3eab2a35
feat: resize native pane dividers with mouse and keyboard
a73x 2026-09-05 09:46
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -26,7 +26,7 @@ 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 pane and picker integration checks; needs python3 | 29 | make native-e2e # ReleaseSafe pane, picker and resize 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 | ||
| @@ -75,8 +75,13 @@ it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send | |||
| 75 | normal terminal encoding through once. Window resizing updates every pane's PTY. | 75 | normal terminal encoding through once. Window resizing updates every pane's PTY. |
| 76 | An exited or unavailable pane leaves other panes usable. | 76 | An exited or unavailable pane leaves other panes usable. |
| 77 | 77 | ||
| 78 | Divider resizing and saved native layouts follow in later | 78 | Drag a divider to resize its panes, or press the prefix then **r** for resize mode. |
| 79 | sprints. Tabs are represented in the ownership model but have no UI yet. Native | 79 | **h/j/k/l** or arrows move the nearest divider on that axis in the indicated |
| 80 | direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes | ||
| 81 | limit movement. Window resizing preserves the chosen proportions. | ||
| 82 | |||
| 83 | Saved native layouts follow in a later sprint. Tabs are represented in the | ||
| 84 | ownership model but have no UI yet. Native | ||
| 80 | layout choices are independent of terminal mux; both clients use the same daemon | 85 | layout choices are independent of terminal mux; both clients use the same daemon |
| 81 | sessions. | 86 | sessions. |
| 82 | 87 | ||
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -121,9 +121,70 @@ At the next retrospective, mark each action with evidence or a carry-forward rea | |||
| 121 | 2026-09-05; no changes or control-specific feedback were requested. | 121 | 2026-09-05; no changes or control-specific feedback were requested. |
| 122 | - [ ] Agree divider hit areas and keyboard resize increments before UI work; | 122 | - [ ] Agree divider hit areas and keyboard resize increments before UI work; |
| 123 | reuse one sizing policy for drag, keyboard input, and minimum constraints. | 123 | reuse one sizing policy for drag, keyboard input, and minimum constraints. |
| 124 | - [ ] Exercise nested divider movement at 200% scale and after a display-scale | 124 | - [x] Exercise nested divider movement at 200% scale and after a display-scale |
| 125 | change. Check both PTY sizes and erased pixels, not only model rectangles. | 125 | change. Check both PTY sizes and erased pixels, not only model rectangles. |
| 126 | - [ ] Include batched resize/input events and stale asynchronous completion in | 126 | - [x] Include batched resize/input events and stale asynchronous completion in |
| 127 | acceptance; avoid observations that accidentally repair the state under test. | 127 | acceptance; avoid observations that accidentally repair the state under test. |
| 128 | - [ ] Keep the bounded implementation/review pair and explicit cleanup package. | 128 | - [x] Keep the bounded implementation/review pair and explicit cleanup package. |
| 129 | Freeze source before final gates and record any retained debt with an owner. | 129 | Freeze source before final gates and record any retained debt with an owner. |
| 130 | |||
| 131 | ## Native tiling — Sprint 3, 2026-09-05 | ||
| 132 | |||
| 133 | Implemented relative divider weights, mouse dragging, and keyboard resize mode. | ||
| 134 | Opening cleanup is separately committed as `3eaad75`; the closing cleanup and | ||
| 135 | adversarial review are complete. Final delivery evidence belongs in the native | ||
| 136 | tiling plan. User acceptance of the resize trial is pending. | ||
| 137 | |||
| 138 | ### What worked | ||
| 139 | |||
| 140 | - The opening cleanup unified live and insertion geometry before adding weights. | ||
| 141 | The closing cleanup consolidated direction mapping, signed pointer conversion, | ||
| 142 | minimum constraints, pixel serialization, and input-state teardown. Both passes | ||
| 143 | had a concrete purpose and avoided unrelated changes. | ||
| 144 | - Direct implementer/reviewer discussion resolved held-key alias leakage, ignored | ||
| 145 | modal keys repeating after exit, and keyboard/picker transitions retaining a | ||
| 146 | mouse drag. Regressions cover the actual triggering event sequences. | ||
| 147 | - Independent kernel PTY measurements caught an inherited mismatch: native panes | ||
| 148 | allowed one content row, while the daemon rejects fewer than two. The GUI now | ||
| 149 | derives its minimum from the shared protocol constants, including its header. | ||
| 150 | - A retained-frame hook observes already-rendered pixels without forcing repaint. | ||
| 151 | PTY checks read `TIOCGWINSZ` without sending input. This makes missed invalidation | ||
| 152 | and missing resize delivery observable instead of accidentally repairing them. | ||
| 153 | - Actual Wayland transitions through 200%, 100%, 150%, and 200% preserved nested | ||
| 154 | resizing, cancelled stale drags, and passed all-pane PTY and erased-pixel checks. | ||
| 155 | An isolated compositor allowed these tests without changing the user's monitor. | ||
| 156 | |||
| 157 | ### What to change | ||
| 158 | |||
| 159 | - Check existing wire limits when defining UI minimums; a model-only test can | ||
| 160 | preserve a locally consistent value that the daemon refuses. | ||
| 161 | - Keep ergonomic proposals distinct from agreement. Six logical pixels and one | ||
| 162 | cell per key were proposed before UI work, then used as trial defaults while | ||
| 163 | awaiting feedback. The outstanding action above remains open until user review. | ||
| 164 | - Preserve practical test limits: tiny-window requests may be constrained by a | ||
| 165 | desktop compositor. Record the actual size granted instead of changing global | ||
| 166 | compositor policy. This sprint achieved exact 8x8 both offscreen and on Sway | ||
| 167 | after removing only the test window's border. | ||
| 168 | |||
| 169 | ### Actions for Sprint 4 | ||
| 170 | |||
| 171 | Owner: the sprint lead and the bounded implementation/review pair. Review these | ||
| 172 | before Sprint 4 and close each with evidence at its retrospective. | ||
| 173 | |||
| 174 | - [ ] Obtain resize demo acceptance and resolve any requested ergonomic changes | ||
| 175 | before extending interaction with detach, end, and recovery actions. | ||
| 176 | - [ ] Start with a bounded cleanup of the lifecycle and state-ownership paths; | ||
| 177 | keep substantial refactors explicit and preserve the closing cleanup package. | ||
| 178 | - [ ] Persist the relative weights and stable pane/tab identity introduced here; | ||
| 179 | round-trip nested layouts after minimum clamping and at a different display scale. | ||
| 180 | - [ ] Validate detach versus end through independently observed daemon sessions, | ||
| 181 | including missing/offline hosts and sessions shared by another client. | ||
| 182 | - [ ] Exercise atomic-save failure, malformed-state preservation, stale asynchronous | ||
| 183 | completions, and cancellation during restoration; do not modify terminal mux's | ||
| 184 | layout state or silently recreate a missing saved session. | ||
| 185 | - [ ] Keep observations passive and final gates on frozen source. Retain the real | ||
| 186 | screen-recording/private Serve demo route documented in | ||
| 187 | [the sprint workflow](docs/native-sprint-workflow.md). | ||
| 188 | |||
| 189 | Relative weights remain in memory until Sprint 4 persistence, whose implementer | ||
| 190 | owns that planned work. No new correctness debt was deferred by the closing review. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1169,8 +1169,12 @@ pub fn build(b: *std.Build) void { | |||
| 1169 | native_picker.addArtifactArg(mux_exe); | 1169 | native_picker.addArtifactArg(mux_exe); |
| 1170 | native_picker.addArtifactArg(muxg_exe); | 1170 | native_picker.addArtifactArg(muxg_exe); |
| 1171 | native_picker.step.dependOn(&native_tiling.step); | 1171 | native_picker.step.dependOn(&native_tiling.step); |
| 1172 | const native_resize = b.addSystemCommand(&.{ "python3", "-B", "test/native_resize.py" }); | ||
| 1173 | native_resize.addArtifactArg(mux_exe); | ||
| 1174 | native_resize.addArtifactArg(muxg_exe); | ||
| 1175 | native_resize.step.dependOn(&native_picker.step); | ||
| 1172 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1176 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1173 | native_e2e_step.dependOn(&native_picker.step); | 1177 | native_e2e_step.dependOn(&native_resize.step); |
| 1174 | 1178 | ||
| 1175 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); | 1179 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); |
| 1176 | // The same list the e2e step passes, in the same order: soak IS that | 1180 | // 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 | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | # Native tiling — delegated delivery plan | 1 | # Native tiling — delegated delivery plan |
| 2 | 2 | ||
| 3 | Design authority: [native tiling spec](../specs/2026-09-05-native-tiling-design.md). | 3 | Design authority: [native tiling spec](../specs/2026-09-05-native-tiling-design.md). |
| 4 | Status: Sprint 2 accepted; Sprint 3 underway, beginning with reviewed cleanup. | 4 | Status: Sprint 2 accepted; Sprint 3 implemented, reviewed, and validated, awaiting |
| 5 | user acceptance of the recorded demo. | ||
| 5 | 6 | ||
| 6 | ## Working agreement | 7 | ## Working agreement |
| 7 | 8 | ||
| @@ -395,5 +396,71 @@ The user was away from the desktop; this is explicit demo acceptance, not a | |||
| 395 | claimed hands-on trial. No interaction changes were requested. See | 396 | claimed hands-on trial. No interaction changes were requested. See |
| 396 | [RETRO.md](../../../RETRO.md) for the closed actions and the timing lesson: | 397 | [RETRO.md](../../../RETRO.md) for the closed actions and the timing lesson: |
| 397 | pre-implementation agreement was proposed, but acceptance arrived after delivery. | 398 | pre-implementation agreement was proposed, but acceptance arrived after delivery. |
| 398 | Sprint 3 is ready to start with divider resizing; native layout persistence and | 399 | Native layout persistence and recovery commands retain their planned Sprint 4 |
| 399 | recovery commands retain their planned Sprint 4 scope. | 400 | scope. |
| 401 | |||
| 402 | ## Sprint 3 — divider resizing | ||
| 403 | |||
| 404 | Ready for user trial. The user requested a bounded cleanup before every sprint in addition | ||
| 405 | to the closing refactor. Commit `3eaad75` records the reusable workflow and the | ||
| 406 | reviewed opening cleanup: `Tree.layout` now owns minimum-footprint expansion and | ||
| 407 | flattening for both live layout and insertion planning. This preserves behavior; | ||
| 408 | `make check` and all 27 existing native ReleaseSafe tests passed before the commit. | ||
| 409 | Logs: `/tmp/muxg-sprint3-preflight-check.log` and | ||
| 410 | `/tmp/muxg-sprint3-preflight-native.log`. | ||
| 411 | |||
| 412 | Packages 3A–3C are implemented and reviewed. Relative weights preserve split | ||
| 413 | proportions across window sizes. Dragging and prefix + `r` use one minimum-clamped | ||
| 414 | divider operation. Keyboard direction selects the nearest matching-axis ancestor; | ||
| 415 | drag capture ends on release, geometry/focus changes, or keyboard modal transitions. | ||
| 416 | The proposed resize defaults are in the spec; ergonomic acceptance remains pending. | ||
| 417 | |||
| 418 | Adversarial review resolved actual-key repeat leakage, ignored modal keys repeating | ||
| 419 | after exit, and mouse drag state surviving picker or resize-key transitions. The | ||
| 420 | independent OS-size oracle found the inherited one-row native minimum was rejected | ||
| 421 | by the daemon. Native minimums now derive from the shared protocol's two columns | ||
| 422 | and two content rows, plus the GUI header. No daemon or CLI policy changed. | ||
| 423 | |||
| 424 | Closing cleanup consolidated direction mapping, signed pointer conversion, shared | ||
| 425 | minimum constraints, framebuffer serialization, and input-state lifetime. The pair | ||
| 426 | reported no outstanding production findings. Relative-weight persistence is the | ||
| 427 | planned responsibility of Sprint 4, not a correctness deferral. | ||
| 428 | |||
| 429 | Validation on frozen production source: | ||
| 430 | |||
| 431 | - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache deps/zig/zig build native native-test | ||
| 432 | -Doptimize=ReleaseSafe --summary all`: 33 native tests passed, with a working | ||
| 433 | ReleaseSafe executable. Log: `/tmp/muxg-resize-final-test.log`. | ||
| 434 | - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache | ||
| 435 | MUXG_OLD_DAEMON=/tmp/muxg-sprint2-old-mux make native-e2e`: all 10 viewer, | ||
| 436 | 14 tiling, 10 picker, and 8 resize checkpoints passed. This includes prior-daemon | ||
| 437 | compatibility, the existing QUIC picker leg, and stale asynchronous completion. | ||
| 438 | Log: `/tmp/muxg-sprint3-native-e2e-final.log`. | ||
| 439 | - `python3 -B test/native_resize.py zig-out/bin/mux zig-out/bin/muxg`: 8/8 offscreen | ||
| 440 | checks passed; with `MUXG_VIDEODRIVER=wayland MUXG_TEST_SWAY_FLOAT=1`, 8/8 passed | ||
| 441 | on real Sway at 200%. Both achieved an exact 8x8 tiny window. Only the owned test | ||
| 442 | window was floated; no global compositor settings changed. The harness reads | ||
| 443 | every session's kernel PTY dimensions and retained framebuffer pixels without | ||
| 444 | sending corrective input or forcing a redraw. Logs: | ||
| 445 | `/tmp/muxg-resize-acceptance.log`, `/tmp/muxg-resize-wayland.log`. | ||
| 446 | - `python3 /tmp/muxg-sprint3-demo/scale-check.py`: actual isolated Wayland at | ||
| 447 | 200% → 100% → 150% → 200% passed nested drag/key resizing, cancellation of an | ||
| 448 | in-progress drag on scale change, all-pane PTY dimensions, erased pixels, and | ||
| 449 | batched resize/input ordering. Log: `/tmp/muxg-sprint3-scale-final.log`; | ||
| 450 | artifacts: `/tmp/muxg-tiling-4d7h2jog`. | ||
| 451 | - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache make ci`: passed formatting, unit tests, | ||
| 452 | shell/source checks, all 115 end-to-end scenarios with 38 convergence points, | ||
| 453 | all 10 agent checks, and throughput limits. Throughput was 7 ms solo, 40 ms | ||
| 454 | attached, and 90 ms detached repaint. Log: `/tmp/muxg-sprint3-ci-final.log`. | ||
| 455 | |||
| 456 | The real Sway recording uses three isolated local daemons, showing keyboard | ||
| 457 | resizing, dragging each divider, and resumed terminal input. It contains 119 | ||
| 458 | continuous compositor frames over 23.8 seconds, no audio, and was visually checked. | ||
| 459 | The recording and all daemon fixtures were stopped cleanly. Retained artifact: | ||
| 460 | `/home/xanderle/.codex/visualizations/2026/09/04/01a06dd2-1bb6-79c3-8a66-bc6bd8f2dedf/native-tiling-sprint3.mp4`. | ||
| 461 | Private demo: `https://charizard.folk-amberjack.ts.net/sprint3/`; HTTPS byte-range | ||
| 462 | playback returned 206. Its loopback server is `/tmp/muxg-sprint3-demo/server.py` | ||
| 463 | on port 18766. The earlier Sprint 2 route remains intact. To remove only this | ||
| 464 | route, use `tailscale serve --https=443 --set-path /sprint3 off`, then stop that | ||
| 465 | owned server. These resize checks use SDL-injected input; hands-on ergonomics and | ||
| 466 | remote-specific resizing were not separately claimed as validated this sprint. | ||
docs/superpowers/specs/2026-09-05-native-tiling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | # Native tiling — draft design and delivery plan | 1 | # Native tiling — draft design and delivery plan |
| 2 | 2 | ||
| 3 | 2026-09-05. Status: Sprint 2 insertion workflow implemented, reviewed, validated, and accepted by the user via the recorded Sway demo. | 3 | 2026-09-05. Status: Sprint 2 accepted. Sprint 3 resizing implemented and reviewed; |
| 4 | native and high-DPI validation passed, with recorded-demo acceptance pending. | ||
| 4 | 5 | ||
| 5 | Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). | 6 | Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). |
| 6 | 7 | ||
| @@ -137,6 +138,32 @@ Mouse click focuses a pane, and dragging a divider resizes it. Terminal mouse | |||
| 137 | reporting remains a separate feature. Pane headers identify host, session, focus, | 138 | reporting remains a separate feature. Pane headers identify host, session, focus, |
| 138 | and connection state; both detach and end actions must be clearly labelled. | 139 | and connection state; both detach and end actions must be clearly labelled. |
| 139 | 140 | ||
| 141 | ### Sprint 3 resize trial | ||
| 142 | |||
| 143 | The following defaults are proposed for the ergonomic trial; user acceptance is | ||
| 144 | pending. Prefix + `r` enters a visibly labelled resize mode. `h`/Left and `l`/Right | ||
| 145 | move the focused pane's nearest ancestor vertical divider left/right by one cell | ||
| 146 | width; `k`/Up and `j`/Down move its nearest ancestor horizontal divider up/down by | ||
| 147 | one cell height. Direction moves the divider in screen coordinates regardless of | ||
| 148 | which child is focused. Repeated keys repeat the step. Enter/Esc exits; modal | ||
| 149 | keys and text do not reach the terminal. A missing divider on that axis is a no-op. | ||
| 150 | |||
| 151 | Mouse dividers have a six-logical-pixel total grab area, converted using the | ||
| 152 | window's drawable density. Overlaps choose the nearest divider centerline, then | ||
| 153 | the deeper divider on a tie. Dragging retains the pointer's initial offset so | ||
| 154 | pressing near a divider does not jump it. Releasing ends the drag. Focus loss or | ||
| 155 | a window/scale geometry change ends the drag while retaining its last applied | ||
| 156 | position. Capture must be released on every exit path. | ||
| 157 | |||
| 158 | Both paths update the same relative split weights and clamp against the recursive | ||
| 159 | minimum footprint. A leaf reserves two cell widths and two terminal rows plus | ||
| 160 | its header, matching the daemon's minimum accepted session size. Ordinary window | ||
| 161 | resizing clamps the displayed cut | ||
| 162 | without rewriting its stored proportion. A window smaller than the tree's minimum | ||
| 163 | footprint retains pane identities and weights and disables explicit divider changes | ||
| 164 | until there is room again. Every resulting pane content size is sent to its PTY. | ||
| 165 | Weights belong to the tabs-aware native model; saving them arrives in Sprint 4. | ||
| 166 | |||
| 140 | ## Lifecycle and persistence | 167 | ## Lifecycle and persistence |
| 141 | 168 | ||
| 142 | Closing the OS window saves the workspace and detaches all panes. It does not | 169 | Closing the OS window saves the workspace and detaches all panes. It does not |
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -78,15 +78,24 @@ pub const Hook = union(enum) { | |||
| 78 | text: []const u8, | 78 | text: []const u8, |
| 79 | key: struct { code: u32, mods: u16 = 0 }, | 79 | key: struct { code: u32, mods: u16 = 0 }, |
| 80 | click: struct { x: f32, y: f32 }, | 80 | click: struct { x: f32, y: f32 }, |
| 81 | pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32 }, | ||
| 81 | state: []const u8, | 82 | state: []const u8, |
| 82 | resize: struct { w: u32, h: u32 }, | 83 | resize: struct { w: u32, h: u32 }, |
| 83 | capture: []const u8, | 84 | capture: []const u8, |
| 85 | capture_last: []const u8, | ||
| 84 | quit, | 86 | quit, |
| 85 | }; | 87 | }; |
| 86 | 88 | ||
| 87 | pub fn parseHook(line: []const u8) ?Hook { | 89 | pub fn parseHook(line: []const u8) ?Hook { |
| 88 | if (std.mem.eql(u8, line, "quit")) return .quit; | 90 | if (std.mem.eql(u8, line, "quit")) return .quit; |
| 89 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; | 91 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; |
| 92 | inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| { | ||
| 93 | if (std.mem.startsWith(u8, line, entry[0])) { | ||
| 94 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | ||
| 95 | return .{ .pointer = .{ .kind = entry[1], .x = std.fmt.parseFloat(f32, line[entry[0].len..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; | ||
| 96 | } | ||
| 97 | } | ||
| 98 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; | ||
| 90 | if (std.mem.startsWith(u8, line, "click:")) { | 99 | if (std.mem.startsWith(u8, line, "click:")) { |
| 91 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 100 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| 92 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; | 101 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; |
| @@ -96,7 +105,7 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 96 | if (std.mem.startsWith(u8, line, "key:")) { | 105 | if (std.mem.startsWith(u8, line, "key:")) { |
| 97 | const name = line["key:".len..]; | 106 | const name = line["key:".len..]; |
| 98 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; | 107 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; |
| 99 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvb", name[0]) != null) return .{ .key = .{ .code = name[0] } }; | 108 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbr", name[0]) != null) return .{ .key = .{ .code = name[0] } }; |
| 100 | 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| { | 109 | 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| { |
| 101 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; | 110 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; |
| 102 | } | 111 | } |
| @@ -153,9 +162,14 @@ const HookReader = struct { | |||
| 153 | text: std.ArrayListUnmanaged([:0]u8) = .empty, | 162 | text: std.ArrayListUnmanaged([:0]u8) = .empty, |
| 154 | capture: ?[]u8 = null, | 163 | capture: ?[]u8 = null, |
| 155 | state: ?[]u8 = null, | 164 | state: ?[]u8 = null, |
| 165 | capture_last: ?[]u8 = null, | ||
| 166 | retain_frame: bool = false, | ||
| 167 | pixels: []u8 = &.{}, | ||
| 168 | pixel_w: u32 = 0, | ||
| 169 | pixel_h: u32 = 0, | ||
| 156 | 170 | ||
| 157 | fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader { | 171 | fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader { |
| 158 | return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0) }; | 172 | return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0), .retain_frame = if (std.posix.getenv("MUXG_TEST_RETAIN_FRAME")) |v| std.mem.eql(u8, v, "1") else false }; |
| 159 | } | 173 | } |
| 160 | 174 | ||
| 161 | fn deinit(self: *HookReader) void { | 175 | fn deinit(self: *HookReader) void { |
| @@ -164,6 +178,8 @@ const HookReader = struct { | |||
| 164 | self.text.deinit(self.alloc); | 178 | self.text.deinit(self.alloc); |
| 165 | if (self.capture) |p| self.alloc.free(p); | 179 | if (self.capture) |p| self.alloc.free(p); |
| 166 | if (self.state) |p| self.alloc.free(p); | 180 | if (self.state) |p| self.alloc.free(p); |
| 181 | if (self.capture_last) |p| self.alloc.free(p); | ||
| 182 | self.alloc.free(self.pixels); | ||
| 167 | } | 183 | } |
| 168 | 184 | ||
| 169 | fn releaseText(self: *HookReader, p: [*c]const u8) void { | 185 | fn releaseText(self: *HookReader, p: [*c]const u8) void { |
| @@ -220,6 +236,21 @@ const HookReader = struct { | |||
| 220 | ev.button.button = c.SDL_BUTTON_LEFT; | 236 | ev.button.button = c.SDL_BUTTON_LEFT; |
| 221 | ev.button.x = at.x; | 237 | ev.button.x = at.x; |
| 222 | ev.button.y = at.y; | 238 | ev.button.y = at.y; |
| 239 | if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed; | ||
| 240 | ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP; | ||
| 241 | }, | ||
| 242 | .pointer => |at| { | ||
| 243 | if (at.kind == .motion) { | ||
| 244 | ev.motion.type = c.SDL_EVENT_MOUSE_MOTION; | ||
| 245 | ev.motion.state = c.SDL_BUTTON_LMASK; | ||
| 246 | ev.motion.x = at.x; | ||
| 247 | ev.motion.y = at.y; | ||
| 248 | } else { | ||
| 249 | ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; | ||
| 250 | ev.button.button = c.SDL_BUTTON_LEFT; | ||
| 251 | ev.button.x = at.x; | ||
| 252 | ev.button.y = at.y; | ||
| 253 | } | ||
| 223 | }, | 254 | }, |
| 224 | .state => |path| { | 255 | .state => |path| { |
| 225 | const copy = try self.alloc.dupe(u8, path); | 256 | const copy = try self.alloc.dupe(u8, path); |
| @@ -237,6 +268,13 @@ const HookReader = struct { | |||
| 237 | self.capture = copy; | 268 | self.capture = copy; |
| 238 | return; | 269 | return; |
| 239 | }, | 270 | }, |
| 271 | .capture_last => |path| { | ||
| 272 | if (!self.retain_frame) return error.FrameRetentionDisabled; | ||
| 273 | const copy = try self.alloc.dupe(u8, path); | ||
| 274 | if (self.capture_last) |old| self.alloc.free(old); | ||
| 275 | self.capture_last = copy; | ||
| 276 | return; | ||
| 277 | }, | ||
| 240 | .quit => ev.type = c.SDL_EVENT_QUIT, | 278 | .quit => ev.type = c.SDL_EVENT_QUIT, |
| 241 | } | 279 | } |
| 242 | if (!c.SDL_PushEvent(&ev)) { | 280 | if (!c.SDL_PushEvent(&ev)) { |
| @@ -271,20 +309,30 @@ const Events = struct { | |||
| 271 | geometry_dirty: bool = false, | 309 | geometry_dirty: bool = false, |
| 272 | suppress_text: bool = false, | 310 | suppress_text: bool = false, |
| 273 | command_mode: bool = false, | 311 | command_mode: bool = false, |
| 312 | resize_mode: bool = false, | ||
| 313 | resize_held: std.AutoHashMapUnmanaged(u32, void) = .empty, | ||
| 314 | logical_w: c_int = 0, | ||
| 315 | logical_h: c_int = 0, | ||
| 316 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64 } = null, | ||
| 274 | consumed_key: ?u32 = null, | 317 | consumed_key: ?u32 = null, |
| 275 | notice: []const u8 = "", | 318 | notice: []const u8 = "", |
| 276 | 319 | ||
| 320 | fn deinit(self: *Events) void { | ||
| 321 | self.cancelDrag(); | ||
| 322 | self.resize_held.deinit(self.rt.alloc); | ||
| 323 | if (self.picker) |picker| picker.deinit(); | ||
| 324 | } | ||
| 277 | fn command(self: *Events, key: u32) !void { | 325 | fn command(self: *Events, key: u32) !void { |
| 326 | self.cancelDrag(); | ||
| 278 | self.command_mode = false; | 327 | self.command_mode = false; |
| 279 | self.notice = ""; | 328 | self.notice = ""; |
| 280 | const ws = &self.rt.workspace; | 329 | const ws = &self.rt.workspace; |
| 281 | switch (key) { | 330 | if (keyDirection(key)) |direction| { |
| 282 | c.SDLK_H, c.SDLK_LEFT => ws.moveFocus(&self.layout, .left), | 331 | ws.moveFocus(&self.layout, direction); |
| 283 | c.SDLK_J, c.SDLK_DOWN => ws.moveFocus(&self.layout, .down), | 332 | } else switch (key) { |
| 284 | c.SDLK_K, c.SDLK_UP => ws.moveFocus(&self.layout, .up), | ||
| 285 | c.SDLK_L, c.SDLK_RIGHT => ws.moveFocus(&self.layout, .right), | ||
| 286 | c.SDLK_V => ws.arm(.beside), | 333 | c.SDLK_V => ws.arm(.beside), |
| 287 | c.SDLK_B => ws.arm(.stacked), | 334 | c.SDLK_B => ws.arm(.stacked), |
| 335 | c.SDLK_R => self.resize_mode = true, | ||
| 288 | c.SDLK_ESCAPE => ws.cancel(), | 336 | c.SDLK_ESCAPE => ws.cancel(), |
| 289 | c.SDLK_RETURN, c.SDLK_KP_ENTER => { | 337 | c.SDLK_RETURN, c.SDLK_KP_ENTER => { |
| 290 | self.picker = picker_mod.Picker.init(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake, Wake.discovery) catch |err| { | 338 | self.picker = picker_mod.Picker.init(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake, Wake.discovery) catch |err| { |
| @@ -293,7 +341,7 @@ const Events = struct { | |||
| 293 | return; | 341 | return; |
| 294 | }; | 342 | }; |
| 295 | }, | 343 | }, |
| 296 | else => self.notice = "v/b split, h/j/k/l focus, Enter picks session, Esc cancel", | 344 | else => self.notice = "v/b split, h/j/k/l focus, r resize, Enter picks session, Esc cancel", |
| 297 | } | 345 | } |
| 298 | self.dirty = true; | 346 | self.dirty = true; |
| 299 | } | 347 | } |
| @@ -301,7 +349,7 @@ const Events = struct { | |||
| 301 | /// Commit events sample the actual drawable even if its resize notice | 349 | /// Commit events sample the actual drawable even if its resize notice |
| 302 | /// is still behind this event in SDL's bounded queue. | 350 | /// is still behind this event in SDL's bounded queue. |
| 303 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 351 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 304 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN) { | 352 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or (self.drag != null and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 305 | self.geometry_dirty = true; | 353 | self.geometry_dirty = true; |
| 306 | try self.refreshGeometry(); | 354 | try self.refreshGeometry(); |
| 307 | } | 355 | } |
| @@ -316,17 +364,40 @@ const Events = struct { | |||
| 316 | if (self.picker) |picker| { | 364 | if (self.picker) |picker| { |
| 317 | try picker.text(std.mem.span(ev.text.text)); | 365 | try picker.text(std.mem.span(ev.text.text)); |
| 318 | self.dirty = true; | 366 | self.dirty = true; |
| 319 | } else if (!self.command_mode) try self.rt.input(std.mem.span(ev.text.text)); | 367 | } else if (!self.command_mode and !self.resize_mode) try self.rt.input(std.mem.span(ev.text.text)); |
| 320 | } | 368 | } |
| 321 | self.suppress_text = false; | 369 | self.suppress_text = false; |
| 322 | }, | 370 | }, |
| 323 | c.SDL_EVENT_KEY_DOWN => { | 371 | c.SDL_EVENT_KEY_DOWN => { |
| 324 | self.suppress_text = false; | 372 | self.suppress_text = false; |
| 325 | const key = ev.key.key; | 373 | const key = ev.key.key; |
| 374 | if (!self.resize_mode and self.resize_held.contains(key)) { | ||
| 375 | self.suppress_text = true; | ||
| 376 | return true; | ||
| 377 | } | ||
| 326 | if (self.consumed_key == key) { | 378 | if (self.consumed_key == key) { |
| 327 | self.suppress_text = true; | 379 | self.suppress_text = true; |
| 328 | return true; | 380 | return true; |
| 329 | } | 381 | } |
| 382 | if (self.resize_mode) { | ||
| 383 | self.cancelDrag(); | ||
| 384 | try self.resize_held.put(self.rt.alloc, key, {}); | ||
| 385 | self.suppress_text = true; | ||
| 386 | if (key == c.SDLK_ESCAPE or key == c.SDLK_RETURN or key == c.SDLK_KP_ENTER) { | ||
| 387 | self.resize_mode = false; | ||
| 388 | self.consumed_key = key; | ||
| 389 | self.notice = ""; | ||
| 390 | } else if (keyDirection(key)) |direction| { | ||
| 391 | if (!self.layout.fits) { | ||
| 392 | self.notice = "Window too small to resize"; | ||
| 393 | } else { | ||
| 394 | self.notice = ""; | ||
| 395 | if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) try self.relayout(); | ||
| 396 | } | ||
| 397 | } | ||
| 398 | self.dirty = true; | ||
| 399 | return true; | ||
| 400 | } | ||
| 330 | if (self.picker) |picker| { | 401 | if (self.picker) |picker| { |
| 331 | const mapped: ?picker_mod.Key = switch (key) { | 402 | const mapped: ?picker_mod.Key = switch (key) { |
| 332 | c.SDLK_UP => .up, | 403 | c.SDLK_UP => .up, |
| @@ -349,6 +420,7 @@ const Events = struct { | |||
| 349 | } | 420 | } |
| 350 | const prefix = key == c.SDLK_BACKSLASH and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; | 421 | const prefix = key == c.SDLK_BACKSLASH and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; |
| 351 | if (prefix) { | 422 | if (prefix) { |
| 423 | self.cancelDrag(); | ||
| 352 | self.suppress_text = true; | 424 | self.suppress_text = true; |
| 353 | if (!ev.key.repeat) { | 425 | if (!ev.key.repeat) { |
| 354 | if (self.command_mode) { | 426 | if (self.command_mode) { |
| @@ -374,12 +446,16 @@ const Events = struct { | |||
| 374 | } | 446 | } |
| 375 | }, | 447 | }, |
| 376 | c.SDL_EVENT_KEY_UP => { | 448 | c.SDL_EVENT_KEY_UP => { |
| 449 | _ = self.resize_held.remove(ev.key.key); | ||
| 377 | if (self.consumed_key == ev.key.key) self.consumed_key = null; | 450 | if (self.consumed_key == ev.key.key) self.consumed_key = null; |
| 378 | self.suppress_text = false; | 451 | self.suppress_text = false; |
| 379 | }, | 452 | }, |
| 380 | c.SDL_EVENT_WINDOW_FOCUS_LOST => { | 453 | c.SDL_EVENT_WINDOW_FOCUS_LOST => { |
| 381 | self.suppress_text = false; | 454 | self.suppress_text = false; |
| 382 | self.command_mode = false; | 455 | self.command_mode = false; |
| 456 | self.resize_mode = false; | ||
| 457 | self.resize_held.clearRetainingCapacity(); | ||
| 458 | self.cancelDrag(); | ||
| 383 | self.consumed_key = null; | 459 | self.consumed_key = null; |
| 384 | self.dirty = true; | 460 | self.dirty = true; |
| 385 | }, | 461 | }, |
| @@ -388,13 +464,24 @@ const Events = struct { | |||
| 388 | var h: c_int = 0; | 464 | var h: c_int = 0; |
| 389 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 465 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 390 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h); | 466 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h); |
| 391 | if (self.picker) |picker| { | 467 | try self.pointerDown(at.x, at.y, grabPixels(w, self.fb_w), grabPixels(h, self.fb_h)); |
| 392 | try picker.click(at.x, at.y); | 468 | if (self.drag != null) _ = c.SDL_CaptureMouse(true); |
| 393 | try self.finishPicker(); | ||
| 394 | } else if (self.layout.hit(at.x, at.y)) |id| _ = self.rt.workspace.focus(id); | ||
| 395 | self.dirty = true; | ||
| 396 | } | 469 | } |
| 397 | }, | 470 | }, |
| 471 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { | ||
| 472 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; | ||
| 473 | if (self.drag != null) { | ||
| 474 | var w: c_int = 0; | ||
| 475 | var h: c_int = 0; | ||
| 476 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | ||
| 477 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; | ||
| 478 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.fb_w); | ||
| 479 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.fb_h); | ||
| 480 | if (x != null and y != null) try self.pointerMove(x.?, y.?); | ||
| 481 | } | ||
| 482 | } | ||
| 483 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.cancelDrag(); | ||
| 484 | }, | ||
| 398 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, | 485 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, |
| 399 | c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, | 486 | c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, |
| 400 | else => if (ev.type == self.wake.event_type and (ev.user.code == 1 or self.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) }))) { | 487 | else => if (ev.type == self.wake.event_type and (ev.user.code == 1 or self.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) }))) { |
| @@ -412,6 +499,32 @@ const Events = struct { | |||
| 412 | self.dirty = true; | 499 | self.dirty = true; |
| 413 | }; | 500 | }; |
| 414 | } | 501 | } |
| 502 | fn pointerDown(self: *Events, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { | ||
| 503 | self.cancelDrag(); | ||
| 504 | if (self.picker) |picker| { | ||
| 505 | try picker.click(x, y); | ||
| 506 | try self.finishPicker(); | ||
| 507 | } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { | ||
| 508 | const d = self.layout.divider(id).?; | ||
| 509 | const position = if (d.direction == .beside) x else y; | ||
| 510 | self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() }; | ||
| 511 | } else if (!self.resize_mode) { | ||
| 512 | if (self.layout.hit(x, y)) |id| _ = self.rt.workspace.focus(id); | ||
| 513 | } | ||
| 514 | self.dirty = true; | ||
| 515 | } | ||
| 516 | fn pointerMove(self: *Events, x: i64, y: i64) !void { | ||
| 517 | const drag = self.drag orelse return; | ||
| 518 | if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); | ||
| 519 | const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); | ||
| 520 | const position = (if (d.direction == .beside) x else y) - drag.offset; | ||
| 521 | if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) try self.relayout(); | ||
| 522 | } | ||
| 523 | fn cancelDrag(self: *Events) void { | ||
| 524 | if (self.drag == null) return; | ||
| 525 | self.drag = null; | ||
| 526 | _ = c.SDL_CaptureMouse(false); | ||
| 527 | } | ||
| 415 | fn sendKey(self: *Events, key: keymap.Event) !void { | 528 | fn sendKey(self: *Events, key: keymap.Event) !void { |
| 416 | var buf: [keymap.max_seq_len]u8 = undefined; | 529 | var buf: [keymap.max_seq_len]u8 = undefined; |
| 417 | const bytes = keymap.encode(key, &buf); | 530 | const bytes = keymap.encode(key, &buf); |
| @@ -427,13 +540,21 @@ const Events = struct { | |||
| 427 | var w: c_int = 0; | 540 | var w: c_int = 0; |
| 428 | var h: c_int = 0; | 541 | var h: c_int = 0; |
| 429 | if (!c.SDL_GetWindowSizeInPixels(self.win, &w, &h)) return error.WindowSizeFailed; | 542 | if (!c.SDL_GetWindowSizeInPixels(self.win, &w, &h)) return error.WindowSizeFailed; |
| 543 | var logical_w: c_int = 0; | ||
| 544 | var logical_h: c_int = 0; | ||
| 545 | if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; | ||
| 546 | if (logical_w != self.logical_w or logical_h != self.logical_h) self.cancelDrag(); | ||
| 547 | self.logical_w = logical_w; | ||
| 548 | self.logical_h = logical_h; | ||
| 430 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); | 549 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); |
| 431 | } | 550 | } |
| 432 | fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void { | 551 | fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void { |
| 433 | _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale)); | 552 | _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale)); |
| 553 | const metrics = measuredMetrics(self.cache.face, scale); | ||
| 554 | if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) self.cancelDrag(); | ||
| 434 | self.fb_w = w; | 555 | self.fb_w = w; |
| 435 | self.fb_h = h; | 556 | self.fb_h = h; |
| 436 | self.metrics = measuredMetrics(self.cache.face, scale); | 557 | self.metrics = metrics; |
| 437 | if (self.picker) |picker| { | 558 | if (self.picker) |picker| { |
| 438 | picker.width = @intCast(@max(w, 0)); | 559 | picker.width = @intCast(@max(w, 0)); |
| 439 | picker.height = @intCast(@max(h, 0)); | 560 | picker.height = @intCast(@max(h, 0)); |
| @@ -450,8 +571,25 @@ fn physicalPoint(x: f32, y: f32, logical_w: c_int, logical_h: c_int, fb_w: c_int | |||
| 450 | return .{ .x = physicalAxis(x, logical_w, fb_w), .y = physicalAxis(y, logical_h, fb_h) }; | 571 | return .{ .x = physicalAxis(x, logical_w, fb_w), .y = physicalAxis(y, logical_h, fb_h) }; |
| 451 | } | 572 | } |
| 452 | fn physicalAxis(v: f32, logical: c_int, pixels: c_int) u32 { | 573 | fn physicalAxis(v: f32, logical: c_int, pixels: c_int) u32 { |
| 453 | if (!std.math.isFinite(v) or v < 0 or logical <= 0 or pixels <= 0) return std.math.maxInt(u32); | 574 | const value = physicalSignedAxis(v, logical, pixels) orelse return std.math.maxInt(u32); |
| 454 | return @intFromFloat(@min(@floor(v * @as(f32, @floatFromInt(pixels)) / @as(f32, @floatFromInt(logical))), 2147483648)); | 575 | return if (value < 0) std.math.maxInt(u32) else @intCast(value); |
| 576 | } | ||
| 577 | fn physicalSignedAxis(v: f32, logical: c_int, pixels: c_int) ?i64 { | ||
| 578 | if (!std.math.isFinite(v) or logical <= 0 or pixels <= 0) return null; | ||
| 579 | return @intFromFloat(std.math.clamp(@floor(@as(f64, v) * @as(f64, @floatFromInt(pixels)) / @as(f64, @floatFromInt(logical))), -2147483648, 2147483648)); | ||
| 580 | } | ||
| 581 | fn grabPixels(logical: c_int, pixels: c_int) u32 { | ||
| 582 | if (logical <= 0 or pixels <= 0) return 0; | ||
| 583 | return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical))); | ||
| 584 | } | ||
| 585 | fn keyDirection(key: u32) ?model.Neighbor { | ||
| 586 | return switch (key) { | ||
| 587 | c.SDLK_H, c.SDLK_LEFT => .left, | ||
| 588 | c.SDLK_J, c.SDLK_DOWN => .down, | ||
| 589 | c.SDLK_K, c.SDLK_UP => .up, | ||
| 590 | c.SDLK_L, c.SDLK_RIGHT => .right, | ||
| 591 | else => null, | ||
| 592 | }; | ||
| 455 | } | 593 | } |
| 456 | 594 | ||
| 457 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | 595 | pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { |
| @@ -511,7 +649,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 511 | var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; | 649 | var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; |
| 512 | defer if (hook) |*h| h.deinit(); | 650 | defer if (hook) |*h| h.deinit(); |
| 513 | 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, .key_path = opts.key_path }; | 651 | 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, .key_path = opts.key_path }; |
| 514 | defer if (events.picker) |picker| picker.deinit(); | 652 | defer events.deinit(); |
| 515 | try events.relayout(); | 653 | try events.relayout(); |
| 516 | var headers: [model.max_panes]Header = @splat(.{}); | 654 | var headers: [model.max_panes]Header = @splat(.{}); |
| 517 | var popup: PopupFrame = .{}; | 655 | var popup: PopupFrame = .{}; |
| @@ -550,6 +688,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 550 | h.state = null; | 688 | h.state = null; |
| 551 | try writeState(alloc, path, &events); | 689 | try writeState(alloc, path, &events); |
| 552 | }; | 690 | }; |
| 691 | if (hook) |*h| if (h.capture_last) |path| if (h.pixels.len != 0) { | ||
| 692 | defer alloc.free(path); | ||
| 693 | h.capture_last = null; | ||
| 694 | try writePixels(alloc, path, h.pixel_w, h.pixel_h, h.pixels); | ||
| 695 | }; | ||
| 553 | if (visible_blink and now >= blink_until) { | 696 | if (visible_blink and now >= blink_until) { |
| 554 | blink_phase = !blink_phase; | 697 | blink_phase = !blink_phase; |
| 555 | blink_until = now + 500; | 698 | blink_until = now + 500; |
| @@ -628,13 +771,22 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 628 | renderer.uploadInstances(instances.items); | 771 | renderer.uploadInstances(instances.items); |
| 629 | timing.upload_us = bench.usSince(&timer); | 772 | timing.upload_us = bench.usSince(&timer); |
| 630 | renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x687888ff); | 773 | renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x687888ff); |
| 631 | if (hook) |*h| if (h.capture) |path| { | 774 | if (hook) |*h| if (h.capture != null or h.retain_frame) { |
| 632 | defer alloc.free(path); | ||
| 633 | h.capture = null; | ||
| 634 | const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h)); | 775 | const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h)); |
| 635 | defer alloc.free(pixels); | 776 | var owned = true; |
| 636 | var header: [64]u8 = undefined; | 777 | defer if (owned) alloc.free(pixels); |
| 637 | try writeArtifact(alloc, path, &.{ try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ events.fb_w, events.fb_h }), pixels }); | 778 | if (h.capture) |path| { |
| 779 | defer alloc.free(path); | ||
| 780 | h.capture = null; | ||
| 781 | try writePixels(alloc, path, @intCast(events.fb_w), @intCast(events.fb_h), pixels); | ||
| 782 | } | ||
| 783 | if (h.retain_frame) { | ||
| 784 | alloc.free(h.pixels); | ||
| 785 | h.pixels = pixels; | ||
| 786 | h.pixel_w = @intCast(events.fb_w); | ||
| 787 | h.pixel_h = @intCast(events.fb_h); | ||
| 788 | owned = false; | ||
| 789 | } | ||
| 638 | }; | 790 | }; |
| 639 | if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow"); | 791 | if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow"); |
| 640 | for (events.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) { | 792 | for (events.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) { |
| @@ -677,7 +829,7 @@ const Header = struct { | |||
| 677 | fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { | 829 | fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { |
| 678 | const focused = events.rt.workspace.tab().focus == p.id; | 830 | const focused = events.rt.workspace.tab().focus == p.id; |
| 679 | const pending = events.rt.workspace.tab().pending; | 831 | const pending = events.rt.workspace.tab().pending; |
| 680 | const hint = if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]") else events.notice; | 832 | const hint = if (focused and events.resize_mode) (if (events.notice.len != 0) events.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]") else events.notice; |
| 681 | const label = events.rt.workspace.pane(p.id).?.identity.label; | 833 | const label = events.rt.workspace.pane(p.id).?.identity.label; |
| 682 | var status_buf: [48]u8 = undefined; | 834 | var status_buf: [48]u8 = undefined; |
| 683 | const status: []const u8 = switch (live.status.phase) { | 835 | const status: []const u8 = switch (live.status.phase) { |
| @@ -762,9 +914,13 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void | |||
| 762 | for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; | 914 | for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; |
| 763 | picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.selected, .notice = picker.noticeText(), .host = picker.host(), .input = picker.input.items, .rect = view.rect, .row_height = view.row_height, .first = view.first }; | 915 | picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.selected, .notice = picker.noticeText(), .host = picker.host(), .input = picker.input.items, .rect = view.rect, .row_height = view.row_height, .first = view.first }; |
| 764 | } | 916 | } |
| 765 | 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, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{}); | 917 | 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, .resize_mode = events.resize_mode, .drag = events.drag, .dividers = events.layout.boundaries(), .notice = events.notice, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{}); |
| 766 | try writeArtifact(a, path, &.{bytes}); | 918 | try writeArtifact(a, path, &.{bytes}); |
| 767 | } | 919 | } |
| 920 | fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { | ||
| 921 | var header: [64]u8 = undefined; | ||
| 922 | try writeArtifact(alloc, path, &.{ try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }), pixels }); | ||
| 923 | } | ||
| 768 | fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void { | 924 | fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void { |
| 769 | const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); | 925 | const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path}); |
| 770 | defer alloc.free(temporary); | 926 | defer alloc.free(temporary); |
| @@ -782,6 +938,125 @@ test "logical pointer coordinates use drawable density independently of font sca | |||
| 782 | // A content scale of two with a density of one must keep these unchanged. | 938 | // A content scale of two with a density of one must keep these unchanged. |
| 783 | try std.testing.expectEqual(@as(u32, 480), physicalPoint(480, 300, 960, 600, 960, 600).x); | 939 | try std.testing.expectEqual(@as(u32, 480), physicalPoint(480, 300, 960, 600, 960, 600).x); |
| 784 | try std.testing.expectEqual(std.math.maxInt(u32), physicalAxis(-1, 960, 1920)); | 940 | try std.testing.expectEqual(std.math.maxInt(u32), physicalAxis(-1, 960, 1920)); |
| 941 | try std.testing.expectEqual(@as(i64, -20), physicalSignedAxis(-10, 960, 1920).?); | ||
| 942 | try std.testing.expect(physicalSignedAxis(std.math.nan(f32), 960, 1920) == null); | ||
| 943 | try std.testing.expectEqual(@as(u32, 12), grabPixels(960, 1920)); | ||
| 944 | try std.testing.expectEqual(@as(u32, 6), grabPixels(960, 960)); | ||
| 945 | } | ||
| 946 | |||
| 947 | test "resize mode repeats move one cell without leaking held directions after exit" { | ||
| 948 | const a = std.testing.allocator; | ||
| 949 | var rt = runtime.Runtime.init(a, .{}); | ||
| 950 | defer rt.deinit(); | ||
| 951 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 952 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); | ||
| 953 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); | ||
| 954 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | ||
| 955 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = metrics, .fb_w = 801, .fb_h = 601 }; | ||
| 956 | defer events.deinit(); | ||
| 957 | try events.relayout(); | ||
| 958 | try events.command(c.SDLK_R); | ||
| 959 | var ev = std.mem.zeroes(c.SDL_Event); | ||
| 960 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 961 | ev.key.key = c.SDLK_H; | ||
| 962 | _ = try events.handle(ev); | ||
| 963 | ev.key.repeat = true; | ||
| 964 | _ = try events.handle(ev); | ||
| 965 | const divider = events.layout.boundaries()[0]; | ||
| 966 | try std.testing.expectEqual(@as(u32, 380), divider.position()); | ||
| 967 | ev.key.repeat = false; | ||
| 968 | ev.key.key = c.SDLK_RETURN; | ||
| 969 | _ = try events.handle(ev); | ||
| 970 | try std.testing.expect(!events.resize_mode); | ||
| 971 | ev.key.key = c.SDLK_H; | ||
| 972 | ev.key.repeat = true; | ||
| 973 | _ = try events.handle(ev); | ||
| 974 | try std.testing.expect(events.suppress_text); | ||
| 975 | try std.testing.expectEqual(divider.rect, events.layout.boundaries()[0].rect); | ||
| 976 | ev.key.type = c.SDL_EVENT_KEY_UP; | ||
| 977 | _ = try events.handle(ev); | ||
| 978 | try std.testing.expect(!events.resize_held.contains(c.SDLK_H)); | ||
| 979 | // Physical aliases and ordinary modal text each retain their own release. | ||
| 980 | try events.command(c.SDLK_R); | ||
| 981 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 982 | ev.key.repeat = false; | ||
| 983 | ev.key.key = c.SDLK_H; | ||
| 984 | _ = try events.handle(ev); | ||
| 985 | ev.key.key = c.SDLK_LEFT; | ||
| 986 | _ = try events.handle(ev); | ||
| 987 | ev.key.type = c.SDL_EVENT_KEY_UP; | ||
| 988 | _ = try events.handle(ev); | ||
| 989 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 990 | ev.key.key = c.SDLK_X; | ||
| 991 | _ = try events.handle(ev); | ||
| 992 | ev.key.key = c.SDLK_ESCAPE; | ||
| 993 | _ = try events.handle(ev); | ||
| 994 | for ([_]u32{ c.SDLK_H, c.SDLK_X }) |held| { | ||
| 995 | ev.key.key = held; | ||
| 996 | ev.key.repeat = true; | ||
| 997 | _ = try events.handle(ev); | ||
| 998 | try std.testing.expect(events.suppress_text); | ||
| 999 | ev.key.type = c.SDL_EVENT_KEY_UP; | ||
| 1000 | _ = try events.handle(ev); | ||
| 1001 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 1002 | } | ||
| 1003 | const resized = events.layout.boundaries()[0]; | ||
| 1004 | try events.command(c.SDLK_R); | ||
| 1005 | events.fb_w = 8; | ||
| 1006 | events.fb_h = 8; | ||
| 1007 | try events.relayout(); | ||
| 1008 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 1009 | ev.key.key = c.SDLK_RIGHT; | ||
| 1010 | _ = try events.handle(ev); | ||
| 1011 | try std.testing.expectEqualStrings("Window too small to resize", events.notice); | ||
| 1012 | events.fb_w = 801; | ||
| 1013 | events.fb_h = 601; | ||
| 1014 | try events.relayout(); | ||
| 1015 | try std.testing.expectEqual(resized.rect, events.layout.boundaries()[0].rect); | ||
| 1016 | } | ||
| 1017 | |||
| 1018 | test "divider drag preserves grab offset clamps signed outside motion and cancels on focus loss" { | ||
| 1019 | const a = std.testing.allocator; | ||
| 1020 | var rt = runtime.Runtime.init(a, .{}); | ||
| 1021 | defer rt.deinit(); | ||
| 1022 | const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 1023 | _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); | ||
| 1024 | _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); | ||
| 1025 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | ||
| 1026 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = metrics, .fb_w = 801, .fb_h = 601 }; | ||
| 1027 | defer events.deinit(); | ||
| 1028 | try events.relayout(); | ||
| 1029 | try events.pointerDown(402, 100, 6, 6); | ||
| 1030 | try std.testing.expect(events.drag != null); | ||
| 1031 | try events.pointerMove(402, 100); | ||
| 1032 | try std.testing.expectEqual(@as(u32, 400), events.layout.boundaries()[0].position()); | ||
| 1033 | try events.pointerMove(452, 100); | ||
| 1034 | try std.testing.expectEqual(@as(u32, 450), events.layout.boundaries()[0].position()); | ||
| 1035 | try events.pointerMove(-100, 100); | ||
| 1036 | try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); | ||
| 1037 | var ev = std.mem.zeroes(c.SDL_Event); | ||
| 1038 | ev.type = c.SDL_EVENT_WINDOW_FOCUS_LOST; | ||
| 1039 | _ = try events.handle(ev); | ||
| 1040 | try std.testing.expect(events.drag == null); | ||
| 1041 | try events.pointerMove(600, 100); | ||
| 1042 | try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); | ||
| 1043 | try events.pointerDown(20, 100, 6, 6); | ||
| 1044 | try events.command(c.SDLK_R); | ||
| 1045 | try std.testing.expect(events.drag == null); | ||
| 1046 | try events.pointerMove(600, 100); | ||
| 1047 | try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); | ||
| 1048 | try events.pointerDown(20, 100, 6, 6); | ||
| 1049 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | ||
| 1050 | ev.key.key = c.SDLK_RIGHT; | ||
| 1051 | _ = try events.handle(ev); | ||
| 1052 | try std.testing.expect(events.drag == null); | ||
| 1053 | try std.testing.expectEqual(@as(u32, 30), events.layout.boundaries()[0].position()); | ||
| 1054 | try events.pointerMove(20, 100); | ||
| 1055 | try std.testing.expectEqual(@as(u32, 30), events.layout.boundaries()[0].position()); | ||
| 1056 | try events.pointerDown(30, 100, 6, 6); | ||
| 1057 | ev.key.key = c.SDLK_ESCAPE; | ||
| 1058 | _ = try events.handle(ev); | ||
| 1059 | try std.testing.expect(events.drag == null and !events.resize_mode); | ||
| 785 | } | 1060 | } |
| 786 | 1061 | ||
| 787 | test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" { | 1062 | test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" { |
| @@ -800,14 +1075,19 @@ test "multi-pane scale transitions resize every content claim and preserve stabl | |||
| 800 | const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); | 1075 | const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); |
| 801 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1076 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 802 | 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 }; | 1077 | 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 }; |
| 1078 | defer events.deinit(); | ||
| 803 | try events.relayout(); | 1079 | try events.relayout(); |
| 804 | const initial = events.layout; | 1080 | const initial = events.layout; |
| 1081 | const boundary = initial.boundaries()[0]; | ||
| 1082 | try events.pointerDown(boundary.rect.x, 100, 6, 6); | ||
| 1083 | try std.testing.expect(events.drag != null); | ||
| 805 | const old_key = rt.get(first).?.key; | 1084 | const old_key = rt.get(first).?.key; |
| 806 | var event = std.mem.zeroes(c.SDL_Event); | 1085 | var event = std.mem.zeroes(c.SDL_Event); |
| 807 | event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED; | 1086 | event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED; |
| 808 | try std.testing.expect(try events.handle(event)); | 1087 | try std.testing.expect(try events.handle(event)); |
| 809 | try std.testing.expect(events.geometry_dirty); | 1088 | try std.testing.expect(events.geometry_dirty); |
| 810 | try events.updateGeometry(960, 600, 2); | 1089 | try events.updateGeometry(960, 600, 2); |
| 1090 | try std.testing.expect(events.drag == null); | ||
| 811 | for (events.layout.items(), initial.items()) |p, before| { | 1091 | for (events.layout.items(), initial.items()) |p, before| { |
| 812 | try std.testing.expect(p.cols < before.cols and p.rows < before.rows); | 1092 | try std.testing.expect(p.cols < before.cols and p.rows < before.rows); |
| 813 | try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols); | 1093 | try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols); |
| @@ -843,6 +1123,7 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 843 | // No live attachment is needed to test the actual SDL event routing state. | 1123 | // No live attachment is needed to test the actual SDL event routing state. |
| 844 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1124 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 845 | 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 }; | 1125 | 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 }; |
| 1126 | defer events.deinit(); | ||
| 846 | var event = std.mem.zeroes(c.SDL_Event); | 1127 | var event = std.mem.zeroes(c.SDL_Event); |
| 847 | event.key.type = c.SDL_EVENT_KEY_DOWN; | 1128 | event.key.type = c.SDL_EVENT_KEY_DOWN; |
| 848 | event.key.key = c.SDLK_BACKSLASH; | 1129 | event.key.key = c.SDLK_BACKSLASH; |
| @@ -986,7 +1267,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until | |||
| 986 | picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; | 1267 | picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null }; |
| 987 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1268 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 988 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker }; | 1269 | var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker }; |
| 989 | defer if (events.picker) |p| p.deinit(); | 1270 | defer events.deinit(); |
| 990 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); | 1271 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); |
| 991 | picker.level = .session_name; | 1272 | picker.level = .session_name; |
| 992 | var event = std.mem.zeroes(c.SDL_Event); | 1273 | var event = std.mem.zeroes(c.SDL_Event); |
src/gui/workspace.zig
| Old | New | ||
|---|---|---|---|
| @@ -6,6 +6,7 @@ const std = @import("std"); | |||
| 6 | const client = @import("client"); | 6 | const client = @import("client"); |
| 7 | 7 | ||
| 8 | pub const PaneId = u64; | 8 | pub const PaneId = u64; |
| 9 | pub const DividerId = u64; | ||
| 9 | pub const TabId = u64; | 10 | pub const TabId = u64; |
| 10 | pub const max_panes = 32; | 11 | pub const max_panes = 32; |
| 11 | pub const Direction = enum { beside, stacked }; | 12 | pub const Direction = enum { beside, stacked }; |
| @@ -31,13 +32,30 @@ pub const Metrics = struct { | |||
| 31 | divider: u16 = 1, | 32 | divider: u16 = 1, |
| 32 | fn minimum(self: Metrics) Rect { | 33 | fn minimum(self: Metrics) Rect { |
| 33 | std.debug.assert(self.cell_w > 0 and self.cell_h > 0); | 34 | 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 | const proto = @import("term").protocol; |
| 36 | return .{ .w = @as(u32, self.cell_w) * proto.min_session_cols, .h = @as(u32, self.cell_h) * (proto.min_session_rows + 1) }; | ||
| 35 | } | 37 | } |
| 36 | }; | 38 | }; |
| 37 | pub const Placement = struct { id: PaneId, outer: Rect, header: Rect, content: Rect, visible: Rect, cols: u16, rows: u16 }; | 39 | pub const Placement = struct { id: PaneId, outer: Rect, header: Rect, content: Rect, visible: Rect, cols: u16, rows: u16 }; |
| 40 | pub const Divider = struct { | ||
| 41 | id: DividerId, | ||
| 42 | direction: Direction, | ||
| 43 | parent: Rect, | ||
| 44 | rect: Rect, | ||
| 45 | visible: Rect, | ||
| 46 | minimum: u32, | ||
| 47 | maximum: u32, | ||
| 48 | pub fn position(self: Divider) u32 { | ||
| 49 | return if (self.direction == .beside) self.rect.x else self.rect.y; | ||
| 50 | } | ||
| 51 | }; | ||
| 38 | pub const Layout = struct { | 52 | pub const Layout = struct { |
| 39 | panes: [max_panes]Placement = undefined, | 53 | panes: [max_panes]Placement = undefined, |
| 40 | len: usize = 0, | 54 | len: usize = 0, |
| 55 | dividers: [max_panes - 1]Divider = undefined, | ||
| 56 | divider_len: usize = 0, | ||
| 57 | viewport: Rect = .{}, | ||
| 58 | fits: bool = true, | ||
| 41 | pub fn items(self: *const Layout) []const Placement { | 59 | pub fn items(self: *const Layout) []const Placement { |
| 42 | return self.panes[0..self.len]; | 60 | return self.panes[0..self.len]; |
| 43 | } | 61 | } |
| @@ -49,6 +67,44 @@ pub const Layout = struct { | |||
| 49 | for (self.items()) |p| if (p.visible.contains(x, y)) return p.id; | 67 | for (self.items()) |p| if (p.visible.contains(x, y)) return p.id; |
| 50 | return null; | 68 | return null; |
| 51 | } | 69 | } |
| 70 | pub fn boundaries(self: *const Layout) []const Divider { | ||
| 71 | return self.dividers[0..self.divider_len]; | ||
| 72 | } | ||
| 73 | pub fn divider(self: *const Layout, id: DividerId) ?Divider { | ||
| 74 | for (self.boundaries()) |d| if (d.id == id) return d; | ||
| 75 | return null; | ||
| 76 | } | ||
| 77 | /// Grab widths are total physical pixels on each axis. Prefer the closest | ||
| 78 | /// line, then the deeper split at intersections; never hit clipped space. | ||
| 79 | pub fn hitDivider(self: *const Layout, x: u32, y: u32, grab_x: u32, grab_y: u32) ?DividerId { | ||
| 80 | if (!self.fits) return null; | ||
| 81 | var best: ?DividerId = null; | ||
| 82 | var distance: u64 = std.math.maxInt(u64); | ||
| 83 | for (self.boundaries()) |d| { | ||
| 84 | var band = d.rect; | ||
| 85 | const vertical = d.direction == .beside; | ||
| 86 | const thickness = if (vertical) band.w else band.h; | ||
| 87 | const size = @max(thickness, if (vertical) grab_x else grab_y); | ||
| 88 | const begin = d.position() -| ((size - thickness) / 2); | ||
| 89 | if (vertical) { | ||
| 90 | band.x = begin; | ||
| 91 | band.w = size; | ||
| 92 | } else { | ||
| 93 | band.y = begin; | ||
| 94 | band.h = size; | ||
| 95 | } | ||
| 96 | if (!band.intersect(d.parent).intersect(self.viewport).contains(x, y)) continue; | ||
| 97 | const coordinate = if (vertical) x else y; | ||
| 98 | const center = @as(u64, d.position()) * 2 + thickness; | ||
| 99 | const point = @as(u64, coordinate) * 2 + 1; | ||
| 100 | const delta = @max(center, point) - @min(center, point); | ||
| 101 | if (delta <= distance) { | ||
| 102 | distance = delta; | ||
| 103 | best = d.id; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | return best; | ||
| 107 | } | ||
| 52 | }; | 108 | }; |
| 53 | 109 | ||
| 54 | pub const Identity = struct { | 110 | pub const Identity = struct { |
| @@ -77,10 +133,13 @@ pub const Identity = struct { | |||
| 77 | } | 133 | } |
| 78 | }; | 134 | }; |
| 79 | pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity }; | 135 | pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity }; |
| 80 | const Node = union(enum) { empty, leaf: PaneId, split: struct { direction: Direction, a: u8, b: u8 } }; | 136 | const Node = union(enum) { empty, leaf: PaneId, split: struct { id: DividerId, direction: Direction, a: u8, b: u8, first: u32 = 1, total: u32 = 2 } }; |
| 81 | const Tree = struct { | 137 | const Tree = struct { |
| 82 | nodes: [max_panes * 2 - 1]Node = @splat(.empty), | 138 | nodes: [max_panes * 2 - 1]Node = @splat(.empty), |
| 83 | root: ?u8 = null, | 139 | root: ?u8 = null, |
| 140 | // Trial divider IDs stay private until insertion commits. Committed IDs | ||
| 141 | // never alias reused node slots, including after sibling promotion. | ||
| 142 | next_divider_id: DividerId = 1, | ||
| 84 | fn free(self: *Tree) !u8 { | 143 | fn free(self: *Tree) !u8 { |
| 85 | for (self.nodes, 0..) |n, i| if (n == .empty) return @intCast(i); | 144 | for (self.nodes, 0..) |n, i| if (n == .empty) return @intCast(i); |
| 86 | return error.WorkspaceFull; | 145 | return error.WorkspaceFull; |
| @@ -101,7 +160,8 @@ const Tree = struct { | |||
| 101 | self.nodes[a] = self.nodes[at]; | 160 | self.nodes[a] = self.nodes[at]; |
| 102 | const b = try self.free(); | 161 | const b = try self.free(); |
| 103 | self.nodes[b] = .{ .leaf = id }; | 162 | self.nodes[b] = .{ .leaf = id }; |
| 104 | self.nodes[at] = .{ .split = .{ .direction = direction, .a = a, .b = b } }; | 163 | self.nodes[at] = .{ .split = .{ .id = self.next_divider_id, .direction = direction, .a = a, .b = b } }; |
| 164 | self.next_divider_id += 1; | ||
| 105 | } | 165 | } |
| 106 | fn remove(self: *Tree, id: PaneId) void { | 166 | fn remove(self: *Tree, id: PaneId) void { |
| 107 | const at = self.leaf(id) orelse return; | 167 | const at = self.leaf(id) orelse return; |
| @@ -137,9 +197,10 @@ const Tree = struct { | |||
| 137 | }; | 197 | }; |
| 138 | } | 198 | } |
| 139 | fn layout(self: *const Tree, width: u32, height: u32, m: Metrics) Layout { | 199 | fn layout(self: *const Tree, width: u32, height: u32, m: Metrics) Layout { |
| 140 | var out: Layout = .{}; | 200 | var out: Layout = .{ .viewport = .{ .w = width, .h = height } }; |
| 141 | if (self.root) |root| { | 201 | if (self.root) |root| { |
| 142 | const min = self.minimum(root, m); | 202 | const min = self.minimum(root, m); |
| 203 | out.fits = width >= min.w and height >= min.h; | ||
| 143 | self.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out); | 204 | self.flatten(root, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &out); |
| 144 | } | 205 | } |
| 145 | return out; | 206 | return out; |
| @@ -156,20 +217,32 @@ const Tree = struct { | |||
| 156 | const bmin = self.minimum(s.b, m); | 217 | const bmin = self.minimum(s.b, m); |
| 157 | var a = rect; | 218 | var a = rect; |
| 158 | var b = rect; | 219 | var b = rect; |
| 220 | var line = rect; | ||
| 221 | const vertical = s.direction == .beside; | ||
| 222 | const space = (if (vertical) rect.w else rect.h) - m.divider; | ||
| 223 | const low = if (vertical) amin.w else amin.h; | ||
| 224 | const high = space - (if (vertical) bmin.w else bmin.h); | ||
| 225 | const intended: u32 = @intCast((@as(u64, space) * s.first + s.total / 2) / s.total); | ||
| 226 | const cut = std.math.clamp(intended, low, high); | ||
| 159 | switch (s.direction) { | 227 | switch (s.direction) { |
| 160 | .beside => { | 228 | .beside => { |
| 161 | const space = rect.w - m.divider; | 229 | a.w = cut; |
| 162 | a.w = std.math.clamp((space + 1) / 2, amin.w, space - bmin.w); | ||
| 163 | b.x += a.w + m.divider; | 230 | b.x += a.w + m.divider; |
| 164 | b.w = space - a.w; | 231 | b.w = space - a.w; |
| 232 | line.x += cut; | ||
| 233 | line.w = m.divider; | ||
| 165 | }, | 234 | }, |
| 166 | .stacked => { | 235 | .stacked => { |
| 167 | const space = rect.h - m.divider; | 236 | a.h = cut; |
| 168 | a.h = std.math.clamp((space + 1) / 2, amin.h, space - bmin.h); | ||
| 169 | b.y += a.h + m.divider; | 237 | b.y += a.h + m.divider; |
| 170 | b.h = space - a.h; | 238 | b.h = space - a.h; |
| 239 | line.y += cut; | ||
| 240 | line.h = m.divider; | ||
| 171 | }, | 241 | }, |
| 172 | } | 242 | } |
| 243 | const start = if (vertical) rect.x else rect.y; | ||
| 244 | out.dividers[out.divider_len] = .{ .id = s.id, .direction = s.direction, .parent = rect, .rect = line, .visible = line.intersect(viewport), .minimum = start + low, .maximum = start + high }; | ||
| 245 | out.divider_len += 1; | ||
| 173 | self.flatten(s.a, a, viewport, m, out); | 246 | self.flatten(s.a, a, viewport, m, out); |
| 174 | self.flatten(s.b, b, viewport, m, out); | 247 | self.flatten(s.b, b, viewport, m, out); |
| 175 | }, | 248 | }, |
| @@ -214,6 +287,50 @@ pub const Workspace = struct { | |||
| 214 | pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout { | 287 | pub fn layout(self: *Workspace, width: u32, height: u32, m: Metrics) Layout { |
| 215 | return self.tab().tree.layout(width, height, m); | 288 | return self.tab().tree.layout(width, height, m); |
| 216 | } | 289 | } |
| 290 | /// Both interaction paths use a fresh layout and the same minimum clamp. | ||
| 291 | /// A no-op must not replace an intended ratio with its temporary clamp. | ||
| 292 | pub fn resizeDivider(self: *Workspace, id: DividerId, position: i64, width: u32, height: u32, m: Metrics) bool { | ||
| 293 | const flat = self.layout(width, height, m); | ||
| 294 | if (!flat.fits) return false; | ||
| 295 | const d = flat.divider(id) orelse return false; | ||
| 296 | const cut: u32 = @intCast(std.math.clamp(position, d.minimum, d.maximum)); | ||
| 297 | if (cut == d.position()) return false; | ||
| 298 | for (&self.tab().tree.nodes) |*n| { | ||
| 299 | if (n.* != .split or n.split.id != id) continue; | ||
| 300 | const vertical = d.direction == .beside; | ||
| 301 | n.split.first = cut - (if (vertical) d.parent.x else d.parent.y); | ||
| 302 | n.split.total = (if (vertical) d.parent.w else d.parent.h) - m.divider; | ||
| 303 | return true; | ||
| 304 | } | ||
| 305 | return false; | ||
| 306 | } | ||
| 307 | pub fn resizeFocused(self: *Workspace, direction: Neighbor, width: u32, height: u32, m: Metrics) bool { | ||
| 308 | const id = self.tab().focus orelse return false; | ||
| 309 | const axis: Direction = switch (direction) { | ||
| 310 | .left, .right => .beside, | ||
| 311 | .up, .down => .stacked, | ||
| 312 | }; | ||
| 313 | const tree = &self.tab().tree; | ||
| 314 | var at = tree.leaf(id) orelse return false; | ||
| 315 | var boundary: ?DividerId = null; | ||
| 316 | while (at != tree.root.?) { | ||
| 317 | var found = false; | ||
| 318 | for (tree.nodes, 0..) |node, index| { | ||
| 319 | if (node != .split or (node.split.a != at and node.split.b != at)) continue; | ||
| 320 | if (node.split.direction == axis) boundary = node.split.id; | ||
| 321 | at = @intCast(index); | ||
| 322 | found = true; | ||
| 323 | break; | ||
| 324 | } | ||
| 325 | std.debug.assert(found); | ||
| 326 | if (boundary != null) break; | ||
| 327 | } | ||
| 328 | const flat = self.layout(width, height, m); | ||
| 329 | const d = flat.divider(boundary orelse return false) orelse return false; | ||
| 330 | const step: i64 = if (axis == .beside) m.cell_w else m.cell_h; | ||
| 331 | const negative = direction == .left or direction == .up; | ||
| 332 | return self.resizeDivider(d.id, @as(i64, d.position()) + (if (negative) -step else step), width, height, m); | ||
| 333 | } | ||
| 217 | pub fn arm(self: *Workspace, direction: Direction) void { | 334 | pub fn arm(self: *Workspace, direction: Direction) void { |
| 218 | if (self.tab().focus) |id| self.tab().pending = .{ .pane = id, .direction = direction }; | 335 | if (self.tab().focus) |id| self.tab().pending = .{ .pane = id, .direction = direction }; |
| 219 | } | 336 | } |
| @@ -429,3 +546,111 @@ test "workspace identity owns nested target arguments beyond caller buffers" { | |||
| 429 | try std.testing.expectEqualStrings("work", hand.session); | 546 | try std.testing.expectEqualStrings("work", hand.session); |
| 430 | try std.testing.expectEqualStrings("host#work", hand.label); | 547 | try std.testing.expectEqualStrings("host#work", hand.label); |
| 431 | } | 548 | } |
| 549 | |||
| 550 | test "weighted nested dividers share keyboard clamps and restore intended proportions" { | ||
| 551 | var w = Workspace.init(std.testing.allocator); | ||
| 552 | defer w.deinit(); | ||
| 553 | const m: Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 554 | const a = try add(&w, 801, 601); | ||
| 555 | const b = try add(&w, 801, 601); | ||
| 556 | w.arm(.stacked); | ||
| 557 | const c = try add(&w, 801, 601); | ||
| 558 | var flat = w.layout(801, 601, m); | ||
| 559 | const outer = flat.boundaries()[0].id; | ||
| 560 | const inner = flat.boundaries()[1].id; | ||
| 561 | try std.testing.expect(w.resizeDivider(outer, 200, 801, 601, m)); | ||
| 562 | try std.testing.expect(w.resizeDivider(inner, 150, 801, 601, m)); | ||
| 563 | flat = w.layout(1601, 1201, m); | ||
| 564 | try std.testing.expectEqual(@as(u32, 400), flat.divider(outer).?.position()); | ||
| 565 | try std.testing.expectEqual(@as(u32, 300), flat.divider(inner).?.position()); | ||
| 566 | |||
| 567 | // Focus is bottom-right: left moves its outer ancestor, up its nearest | ||
| 568 | // horizontal ancestor. Direction describes the divider, not pane growth. | ||
| 569 | try std.testing.expect(w.resizeFocused(.left, 801, 601, m)); | ||
| 570 | try std.testing.expect(w.resizeFocused(.up, 801, 601, m)); | ||
| 571 | flat = w.layout(801, 601, m); | ||
| 572 | try std.testing.expectEqual(@as(u32, 190), flat.divider(outer).?.position()); | ||
| 573 | try std.testing.expectEqual(@as(u32, 130), flat.divider(inner).?.position()); | ||
| 574 | try std.testing.expectEqual(c, w.tab().focus.?); | ||
| 575 | try std.testing.expectEqual(@as(u32, 190), flat.get(a).?.outer.w); | ||
| 576 | try std.testing.expectEqual(@as(u32, 130), flat.get(b).?.outer.h); | ||
| 577 | |||
| 578 | const restored = flat; | ||
| 579 | const tiny = w.layout(10, 10, m); | ||
| 580 | try std.testing.expect(!tiny.fits); | ||
| 581 | try std.testing.expect(!w.resizeFocused(.right, 10, 10, m)); | ||
| 582 | try std.testing.expect(!w.resizeDivider(inner, 1000, 10, 10, m)); | ||
| 583 | try std.testing.expect(tiny.hitDivider(5, 5, 100, 100) == null); | ||
| 584 | for (tiny.items()) |p| try std.testing.expect(p.cols > 0 and p.rows > 0); | ||
| 585 | try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items()); | ||
| 586 | // At exactly the recursive minimum, a clamped no-op also retains ratios. | ||
| 587 | try std.testing.expect(!w.resizeDivider(outer, -100, 41, 121, m)); | ||
| 588 | try std.testing.expectEqualDeep(restored.items(), w.layout(801, 601, m).items()); | ||
| 589 | try std.testing.expect(w.resizeDivider(outer, -100, 801, 601, m)); | ||
| 590 | try std.testing.expectEqual(@as(u32, 20), w.layout(801, 601, m).divider(outer).?.position()); | ||
| 591 | try std.testing.expect(w.resizeDivider(inner, 10000, 801, 601, m)); | ||
| 592 | try std.testing.expectEqual(@as(u32, 540), w.layout(801, 601, m).divider(inner).?.position()); | ||
| 593 | const clamped = w.layout(801, 601, m); | ||
| 594 | try std.testing.expectEqual(@as(u16, @import("term").protocol.min_session_rows), clamped.get(c).?.rows); | ||
| 595 | try std.testing.expectEqual(@as(u16, @import("term").protocol.min_session_cols), clamped.get(a).?.cols); | ||
| 596 | } | ||
| 597 | |||
| 598 | test "divider IDs survive sibling promotion and never alias reused slots" { | ||
| 599 | var w = Workspace.init(std.testing.allocator); | ||
| 600 | defer w.deinit(); | ||
| 601 | const m: Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 602 | const a = try add(&w, 801, 601); | ||
| 603 | const b = try add(&w, 801, 601); | ||
| 604 | w.arm(.stacked); | ||
| 605 | _ = try add(&w, 801, 601); | ||
| 606 | const before = w.layout(801, 601, m); | ||
| 607 | const removed = before.boundaries()[0].id; | ||
| 608 | const promoted = before.boundaries()[1].id; | ||
| 609 | try std.testing.expect(w.resizeDivider(promoted, 200, 801, 601, m)); | ||
| 610 | w.remove(a); | ||
| 611 | try std.testing.expectEqual(@as(u32, 200), w.layout(801, 601, m).divider(promoted).?.position()); | ||
| 612 | _ = w.focus(b); | ||
| 613 | w.arm(.beside); | ||
| 614 | _ = try add(&w, 801, 601); | ||
| 615 | const after = w.layout(801, 601, m); | ||
| 616 | try std.testing.expect(after.divider(removed) == null); | ||
| 617 | try std.testing.expect(!w.resizeDivider(removed, 400, 801, 601, m)); | ||
| 618 | try std.testing.expect(after.boundaries()[1].id > promoted); | ||
| 619 | try std.testing.expectEqual(@as(u32, 200), after.divider(promoted).?.position()); | ||
| 620 | } | ||
| 621 | |||
| 622 | test "keyboard resize chooses the closest matching ancestor and leaves the outer cut unchanged" { | ||
| 623 | var w = Workspace.init(std.testing.allocator); | ||
| 624 | defer w.deinit(); | ||
| 625 | const m: Metrics = .{ .cell_w = 10, .cell_h = 20 }; | ||
| 626 | _ = try add(&w, 801, 601); | ||
| 627 | _ = try add(&w, 801, 601); | ||
| 628 | _ = try add(&w, 801, 601); | ||
| 629 | const before = w.layout(801, 601, m); | ||
| 630 | const outer = before.boundaries()[0]; | ||
| 631 | const inner = before.boundaries()[1]; | ||
| 632 | try std.testing.expect(w.resizeFocused(.left, 801, 601, m)); | ||
| 633 | const after = w.layout(801, 601, m); | ||
| 634 | try std.testing.expectEqual(outer.rect, after.divider(outer.id).?.rect); | ||
| 635 | try std.testing.expectEqual(inner.position() - 10, after.divider(inner.id).?.position()); | ||
| 636 | } | ||
| 637 | |||
| 638 | test "divider hit areas respect axis density nested boundaries and viewport clipping" { | ||
| 639 | var w = Workspace.init(std.testing.allocator); | ||
| 640 | defer w.deinit(); | ||
| 641 | _ = try add(&w, 801, 601); | ||
| 642 | _ = try add(&w, 801, 601); | ||
| 643 | w.arm(.stacked); | ||
| 644 | _ = try add(&w, 801, 601); | ||
| 645 | const flat = w.layout(801, 601, .{ .cell_w = 10, .cell_h = 20 }); | ||
| 646 | const vertical = flat.boundaries()[0]; | ||
| 647 | const horizontal = flat.boundaries()[1]; | ||
| 648 | try std.testing.expectEqual(vertical.id, flat.hitDivider(395, 100, 12, 6).?); | ||
| 649 | try std.testing.expect(flat.hitDivider(394, 100, 12, 6) == null); | ||
| 650 | try std.testing.expectEqual(horizontal.id, flat.hitDivider(600, 298, 12, 6).?); | ||
| 651 | try std.testing.expect(flat.hitDivider(600, 297, 12, 6) == null); | ||
| 652 | try std.testing.expect(flat.hitDivider(100, 300, 12, 6) == null); | ||
| 653 | try std.testing.expect(flat.hitDivider(400, 601, 12, 6) == null); | ||
| 654 | try std.testing.expectEqual(vertical.id, flat.hitDivider(400, 300, 12, 6).?); | ||
| 655 | try std.testing.expectEqual(horizontal.id, flat.hitDivider(401, 300, 12, 6).?); | ||
| 656 | } | ||
test/native_resize.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,334 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real SDL divider input, three daemon PTYs, and nonforcing frame oracles.""" | ||
| 3 | import fcntl | ||
| 4 | import json | ||
| 5 | import os | ||
| 6 | from pathlib import Path | ||
| 7 | import shlex | ||
| 8 | import struct | ||
| 9 | import subprocess | ||
| 10 | import sys | ||
| 11 | import termios | ||
| 12 | |||
| 13 | sys.dont_write_bytecode = True | ||
| 14 | from native_tiling import Rig, colour_counts, eventually, require | ||
| 15 | |||
| 16 | |||
| 17 | class ResizeRig(Rig): | ||
| 18 | def __init__(self, mux, muxg): | ||
| 19 | super().__init__(mux, muxg) | ||
| 20 | self.env['MUXG_TEST_RETAIN_FRAME'] = '1' | ||
| 21 | self.tty_paths = {} | ||
| 22 | |||
| 23 | def launch_gui(self, args, label, attached=True): | ||
| 24 | state = super().launch_gui(args, label, attached) | ||
| 25 | if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': | ||
| 26 | require(self.env['SDL_VIDEO_DRIVER'] == 'wayland', 'Sway floating requires Wayland') | ||
| 27 | # Remove only this fixture's compositor decorations so requested | ||
| 28 | # dimensions describe the SDL client area, as in the offscreen run. | ||
| 29 | command = (f'[pid={self.gui.pid}] floating enable, border none, ' | ||
| 30 | 'resize set width 1000 px height 650 px') | ||
| 31 | result = subprocess.run(['swaymsg', '-r', command], env=self.env, | ||
| 32 | capture_output=True, text=True, check=True, timeout=3) | ||
| 33 | require(all(item['success'] for item in json.loads(result.stdout)), | ||
| 34 | 'could not float this test window') | ||
| 35 | state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (1000, 650)) | ||
| 36 | require(state['width'] == state['logical_width'] * 2 and | ||
| 37 | state['height'] == state['logical_height'] * 2, | ||
| 38 | 'Wayland resize acceptance requires the expected 200% density') | ||
| 39 | return state | ||
| 40 | |||
| 41 | def logical(self, state, x, y): | ||
| 42 | return (x * state['logical_width'] / state['width'], | ||
| 43 | y * state['logical_height'] / state['height']) | ||
| 44 | |||
| 45 | def point(self, state, x, y): | ||
| 46 | x, y = self.logical(state, x, y) | ||
| 47 | return f'{x:.4f},{y:.4f}' | ||
| 48 | |||
| 49 | def focus(self, pane_id): | ||
| 50 | state = self.state() | ||
| 51 | pane = by_id(state)[pane_id] | ||
| 52 | rect = pane['header'] | ||
| 53 | self.send('click:' + self.point(state, rect['x'] + rect['w'] / 2, | ||
| 54 | rect['y'] + rect['h'] / 2)) | ||
| 55 | self.wait_state(lambda s: s['focus'] == pane_id) | ||
| 56 | |||
| 57 | def drag(self, direction, dx=0, dy=0, pad=0): | ||
| 58 | state = self.state() | ||
| 59 | divider = one_divider(state, direction) | ||
| 60 | rect = divider['rect'] | ||
| 61 | x, y = rect['x'] + rect['w'] / 2, rect['y'] + rect['h'] / 2 | ||
| 62 | # Avoid the nested divider intersection. Pad probes the visual rail's | ||
| 63 | # surrounding logical-pixel grab area without changing the target delta. | ||
| 64 | if direction == 'beside': | ||
| 65 | y = rect['y'] + rect['h'] / 4 | ||
| 66 | x += pad * state['width'] / state['logical_width'] | ||
| 67 | else: | ||
| 68 | x = rect['x'] + rect['w'] * 3 / 4 | ||
| 69 | y += pad * state['height'] / state['logical_height'] | ||
| 70 | self.send('mousedown:' + self.point(state, x, y), | ||
| 71 | 'mousemove:' + self.point(state, x + dx, y + dy), | ||
| 72 | 'mouseup:' + self.point(state, x + dx, y + dy)) | ||
| 73 | return state | ||
| 74 | |||
| 75 | def last_pixels(self): | ||
| 76 | raw = self.artifact('capture-last', '.ppm').read_bytes() | ||
| 77 | magic, dims, maximum, pixels = raw.split(b'\n', 3) | ||
| 78 | require(magic == b'P6' and maximum == b'255', 'invalid last-frame capture') | ||
| 79 | width, height = map(int, dims.split()) | ||
| 80 | require(len(pixels) == width * height * 3, 'truncated last-frame capture') | ||
| 81 | return width, height, pixels | ||
| 82 | |||
| 83 | |||
| 84 | def by_id(state): | ||
| 85 | return {pane['id']: pane for pane in state['panes']} | ||
| 86 | |||
| 87 | |||
| 88 | def one_divider(state, direction): | ||
| 89 | found = [d for d in state['dividers'] if d['direction'] == direction] | ||
| 90 | require(len(found) == 1, 'expected one ' + direction + ' divider') | ||
| 91 | return found[0] | ||
| 92 | |||
| 93 | |||
| 94 | def edge(state, direction): | ||
| 95 | return one_divider(state, direction)['rect']['x' if direction == 'beside' else 'y'] | ||
| 96 | |||
| 97 | |||
| 98 | def unchanged_ids(state, identities): | ||
| 99 | require([(p['id'], p['generation']) for p in state['panes']] == identities, | ||
| 100 | 'resizing replaced, reordered or reattached a pane') | ||
| 101 | require(all(p['cols'] >= 2 and p['rows'] >= 2 for p in state['panes']), | ||
| 102 | 'resizing violated a terminal minimum') | ||
| 103 | |||
| 104 | |||
| 105 | def pty_sizes(rig, sessions, identities): | ||
| 106 | state = rig.state() | ||
| 107 | unchanged_ids(state, identities) | ||
| 108 | require([(d['id'], d['direction']) for d in state['dividers']] == rig.divider_ids, | ||
| 109 | 'resizing replaced or reordered divider identities') | ||
| 110 | for pane_id, (sock, session) in sessions.items(): | ||
| 111 | pane = by_id(state)[pane_id] | ||
| 112 | expected = (pane['content']['h'] // state['cell_h'], | ||
| 113 | pane['content']['w'] // state['cell_w']) | ||
| 114 | require(expected == (pane['rows'], pane['cols']), 'geometry and cell claim disagree') | ||
| 115 | actual = None | ||
| 116 | def kernel_size(): | ||
| 117 | nonlocal actual | ||
| 118 | fd = os.open(rig.tty_paths[pane_id], os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK) | ||
| 119 | try: | ||
| 120 | actual = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))[:2] | ||
| 121 | return actual == expected | ||
| 122 | finally: | ||
| 123 | os.close(fd) | ||
| 124 | try: | ||
| 125 | eventually(kernel_size, f'{session} kernel PTY size did not become {expected}') | ||
| 126 | except AssertionError as error: | ||
| 127 | raise AssertionError(f'{error}; last observed {actual}') from error | ||
| 128 | # Both observations are read-only. Neither sends terminal input or | ||
| 129 | # attaches at a new size, which could mask a missing resize notification. | ||
| 130 | observed = rig.status(sock, session) | ||
| 131 | require((observed['rows'], observed['cols']) == expected, 'daemon dimension mismatch') | ||
| 132 | |||
| 133 | |||
| 134 | def build_nested(rig): | ||
| 135 | a, _ = rig.daemon('resize-left') | ||
| 136 | b, _ = rig.daemon('resize-upper') | ||
| 137 | c, _ = rig.daemon('resize-lower') | ||
| 138 | rig.catalogue(['--sock ' + sock for sock in (a, b, c)]) | ||
| 139 | rig.launch_gui(['--sock', a, '--session', 'left'], 'gui-resize') | ||
| 140 | rig.host('--sock ' + b, axis='v') | ||
| 141 | rig.new_session('upper') | ||
| 142 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 143 | rig.host('--sock ' + c, axis='b') | ||
| 144 | rig.new_session('lower') | ||
| 145 | state = rig.wait_state(lambda s: len(s['panes']) == 3 and all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 146 | identities = [(p['id'], p['generation']) for p in state['panes']] | ||
| 147 | rig.divider_ids = [(d['id'], d['direction']) for d in state['dividers']] | ||
| 148 | sessions = {identities[0][0]: (a, 'left'), identities[1][0]: (b, 'upper'), | ||
| 149 | identities[2][0]: (c, 'lower')} | ||
| 150 | for pane_id in sessions: | ||
| 151 | rig.focus(pane_id) | ||
| 152 | path = rig.root / f'tty-{pane_id}' | ||
| 153 | rig.shell('tty > ' + shlex.quote(str(path))) | ||
| 154 | eventually(lambda: path.exists() and path.stat().st_size > 0, 'tty path did not arrive') | ||
| 155 | rig.tty_paths[pane_id] = path.read_text().strip() | ||
| 156 | pty_sizes(rig, sessions, identities) | ||
| 157 | rig.ok('three nested panes have stable identities and independently measured PTY sizes') | ||
| 158 | return identities, sessions | ||
| 159 | |||
| 160 | |||
| 161 | def drag_and_keys(rig, identities, sessions): | ||
| 162 | state = rig.state() | ||
| 163 | dx = state['cell_w'] * 5 | ||
| 164 | before = rig.drag('beside', dx=dx, pad=2) | ||
| 165 | state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') - dx) <= 1) | ||
| 166 | require(edge(state, 'stacked') == edge(before, 'stacked'), 'outer drag moved inner horizontal rail') | ||
| 167 | old, new = by_id(before), by_id(state) | ||
| 168 | for pane_id in (identities[1][0], identities[2][0]): | ||
| 169 | require(abs(new[pane_id]['outer']['x'] - old[pane_id]['outer']['x'] - dx) <= 1, | ||
| 170 | 'outer drag did not move both descendants') | ||
| 171 | rig.send('mousemove:0,0') | ||
| 172 | require(edge(rig.state(), 'beside') == edge(state, 'beside'), | ||
| 173 | 'pointer motion after release continued resizing') | ||
| 174 | dy = -state['cell_h'] * 3 | ||
| 175 | before = rig.drag('stacked', dy=dy, pad=2) | ||
| 176 | state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - dy) <= 1) | ||
| 177 | require(by_id(state)[identities[0][0]]['outer'] == by_id(before)[identities[0][0]]['outer'], | ||
| 178 | 'nested horizontal drag changed the independent left pane') | ||
| 179 | pty_sizes(rig, sessions, identities) | ||
| 180 | rig.ok('real divider drags resize descendants on both axes, including the wider grab area') | ||
| 181 | |||
| 182 | rig.focus(identities[2][0]) | ||
| 183 | before = rig.state() | ||
| 184 | rig.chord('r') | ||
| 185 | rig.wait_state(lambda s: s['resize_mode']) | ||
| 186 | rig.key('left') | ||
| 187 | state = rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(before, 'beside') + s['cell_w']) <= 1) | ||
| 188 | require(edge(state, 'stacked') == edge(before, 'stacked'), 'horizontal key chose wrong ancestor') | ||
| 189 | before = state | ||
| 190 | rig.key('j') | ||
| 191 | state = rig.wait_state(lambda s: abs(edge(s, 'stacked') - edge(before, 'stacked') - s['cell_h']) <= 1) | ||
| 192 | require(edge(state, 'beside') == edge(before, 'beside'), 'vertical key moved outer divider') | ||
| 193 | rig.key('escape') | ||
| 194 | rig.wait_state(lambda s: not s['resize_mode']) | ||
| 195 | pty_sizes(rig, sessions, identities) | ||
| 196 | rig.ok('keyboard resize chooses the nearest matching-axis divider and moves it one cell') | ||
| 197 | |||
| 198 | |||
| 199 | def modal_leakage(rig, identities): | ||
| 200 | pane_id = identities[2][0] | ||
| 201 | for exit_key in ('enter', 'escape'): | ||
| 202 | rig.focus(pane_id) | ||
| 203 | root = rig.root / ('modal-' + exit_key) | ||
| 204 | ready, received, done = [Path(str(root) + suffix) for suffix in ('.ready', '.bytes', '.done')] | ||
| 205 | command = ('stty -echo -icanon min 1 time 0; : > ' + shlex.quote(str(ready)) + | ||
| 206 | '; dd bs=1 count=1 2>/dev/null | od -An -tx1 > ' + shlex.quote(str(received)) + | ||
| 207 | '; stty sane; : > ' + shlex.quote(str(done))) | ||
| 208 | rig.shell(command) | ||
| 209 | eventually(ready.exists, 'raw terminal fixture did not arm') | ||
| 210 | rig.send('key:prefix', 'key:r', 'text:r', 'key:l', 'text:l', | ||
| 211 | 'key:j', 'text:j', 'key:left', 'key:up', 'text:MODAL-LEAK', | ||
| 212 | 'key:' + exit_key, 'text:Q') | ||
| 213 | eventually(done.exists, 'terminal did not receive post-resize sentinel') | ||
| 214 | require(received.read_text().split() == ['51'], 'resize mode leaked input before sentinel Q') | ||
| 215 | rig.wait_state(lambda s: not s['resize_mode']) | ||
| 216 | rig.ok('resize keys and associated text stay modal; Enter and Esc release ordinary input') | ||
| 217 | |||
| 218 | |||
| 219 | def clamps(rig, identities, sessions): | ||
| 220 | rig.drag('beside', dx=10000) | ||
| 221 | rig.wait_state(lambda s: all(by_id(s)[pane_id]['cols'] == 2 | ||
| 222 | for pane_id in (identities[1][0], identities[2][0]))) | ||
| 223 | rig.drag('stacked', dy=10000) | ||
| 224 | rig.wait_state(lambda s: by_id(s)[identities[2][0]]['rows'] == 2) | ||
| 225 | pty_sizes(rig, sessions, identities) | ||
| 226 | rig.drag('beside', dx=-10000) | ||
| 227 | rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] == 2) | ||
| 228 | rig.drag('stacked', dy=-10000) | ||
| 229 | state = rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] == 2) | ||
| 230 | unchanged_ids(state, identities) | ||
| 231 | pty_sizes(rig, sessions, identities) | ||
| 232 | before_tiny = state | ||
| 233 | rig.send('resize:8x8') | ||
| 234 | if os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1': | ||
| 235 | state = rig.wait_state(lambda s: s['logical_width'] <= 100 and s['logical_height'] <= 100) | ||
| 236 | if (state['logical_width'], state['logical_height']) != (8, 8): | ||
| 237 | print('resize note: Sway minimum clamped requested 8x8 to ' | ||
| 238 | f"{state['logical_width']}x{state['logical_height']}; exact 8x8 covered offscreen", flush=True) | ||
| 239 | else: | ||
| 240 | state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (8, 8)) | ||
| 241 | unchanged_ids(state, identities) | ||
| 242 | pty_sizes(rig, sessions, identities) | ||
| 243 | rig.send('key:prefix', 'key:r', 'key:l', 'key:j', 'key:escape') | ||
| 244 | require([p['outer'] for p in rig.state()['panes']] == [p['outer'] for p in state['panes']], | ||
| 245 | 'explicit resizing changed an undersized tree') | ||
| 246 | restored_size = (before_tiny['logical_width'], before_tiny['logical_height']) | ||
| 247 | rig.send(f'resize:{restored_size[0]}x{restored_size[1]}') | ||
| 248 | restored = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == restored_size) | ||
| 249 | require([p['outer'] for p in restored['panes']] == [p['outer'] for p in before_tiny['panes']], | ||
| 250 | 'tiny-window clipping rewrote split proportions') | ||
| 251 | # Restore usable widths/heights through the same real drag path. | ||
| 252 | state = rig.state() | ||
| 253 | rig.drag('beside', dx=state['width'] * .45 - edge(state, 'beside')) | ||
| 254 | state = rig.wait_state(lambda s: by_id(s)[identities[0][0]]['cols'] > 20) | ||
| 255 | rig.drag('stacked', dy=state['height'] * .5 - edge(state, 'stacked')) | ||
| 256 | rig.wait_state(lambda s: by_id(s)[identities[1][0]]['rows'] > 5) | ||
| 257 | pty_sizes(rig, sessions, identities) | ||
| 258 | rig.ok('minimum clamps and tiny-window clipping preserve every identity and nonzero PTY') | ||
| 259 | |||
| 260 | |||
| 261 | def pixels_and_batch(rig, identities, sessions): | ||
| 262 | for index, (pane_id, (sock, session)) in enumerate(sessions.items()): | ||
| 263 | rig.focus(pane_id) | ||
| 264 | colour = ('255;0;0', '0;255;0', '0;0;255')[index] | ||
| 265 | marker = 'COLOUR-' + session | ||
| 266 | rig.shell("printf '\\033[?25l\\033[48;2;" + colour + "m\\033[2J\\033[HCOLOUR-%s\\n' " + shlex.quote(session)) | ||
| 267 | rig.wait_marker(sock, session, marker) | ||
| 268 | rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text']) | ||
| 269 | def clean_regions(): | ||
| 270 | state = rig.state() | ||
| 271 | capture = rig.last_pixels() | ||
| 272 | for index, (pane_id, _) in enumerate(identities): | ||
| 273 | inside, outside = colour_counts(capture, by_id(state)[pane_id]['content'], ('red', 'green', 'blue')[index]) | ||
| 274 | if inside < 100 or outside != 0: | ||
| 275 | return False | ||
| 276 | return True | ||
| 277 | eventually(clean_regions, 'initial nonforcing framebuffer colours are misplaced') | ||
| 278 | frames, _ = rig.frames() | ||
| 279 | rig.drag('beside', dx=-rig.state()['cell_w'] * 7) | ||
| 280 | eventually(lambda: rig.frames()[0] > frames, 'divider drag did not cause a completed frame') | ||
| 281 | eventually(clean_regions, 'old coloured pixels survived the divider moving, or crossed neighbours') | ||
| 282 | rig.ok('nonforcing framebuffer reads show old pixels erased after divider motion') | ||
| 283 | |||
| 284 | # One FIFO write places release, window resize, and terminal input into the | ||
| 285 | # normal SDL queue together. The focused terminal asks its OS for the size | ||
| 286 | # that accompanied this command, not a later observer-triggered correction. | ||
| 287 | rig.focus(identities[2][0]) | ||
| 288 | state = rig.state() | ||
| 289 | rail = one_divider(state, 'stacked')['rect'] | ||
| 290 | x, y = rail['x'] + rail['w'] * .75, rail['y'] + rail['h'] / 2 | ||
| 291 | output = rig.root / 'batched-pty-size' | ||
| 292 | rig.send('mousedown:' + rig.point(state, x, y), | ||
| 293 | 'mousemove:' + rig.point(state, x, y + state['cell_h']), | ||
| 294 | 'mouseup:' + rig.point(state, x, y + state['cell_h']), | ||
| 295 | 'resize:800x520', 'text:stty size > ' + shlex.quote(str(output)), 'key:enter') | ||
| 296 | eventually(lambda: output.exists() and output.stat().st_size > 0, 'batched terminal input did not run') | ||
| 297 | state = rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (800, 520)) | ||
| 298 | focused = by_id(state)[identities[2][0]] | ||
| 299 | require(tuple(map(int, output.read_text().split())) == (focused['rows'], focused['cols']), | ||
| 300 | 'batched resize/input ran against stale PTY dimensions') | ||
| 301 | pty_sizes(rig, sessions, identities) | ||
| 302 | rig.ok('batched drag release, window resize and input preserve final PTY size ordering') | ||
| 303 | |||
| 304 | |||
| 305 | def main(): | ||
| 306 | require(len(sys.argv) == 3, 'usage: native_resize.py MUX MUXG') | ||
| 307 | rig = ResizeRig(*sys.argv[1:3]) | ||
| 308 | try: | ||
| 309 | version = subprocess.run([rig.muxg, '--version'], capture_output=True, text=True, check=True) | ||
| 310 | require(any(mode in version.stdout + version.stderr for mode in ('ReleaseSafe', 'ReleaseFast')), | ||
| 311 | 'resize acceptance requires a release build') | ||
| 312 | identities, sessions = build_nested(rig) | ||
| 313 | drag_and_keys(rig, identities, sessions) | ||
| 314 | modal_leakage(rig, identities) | ||
| 315 | clamps(rig, identities, sessions) | ||
| 316 | pixels_and_batch(rig, identities, sessions) | ||
| 317 | rig.quit() | ||
| 318 | for sock, session in sessions.values(): | ||
| 319 | require(rig.command('a', 'status', '--sock', sock, '--session', session, | ||
| 320 | '--timeout', '1000', check=False).returncode == 0, | ||
| 321 | 'window close ended a resized session') | ||
| 322 | rig.ok('window close preserves all three resized sessions') | ||
| 323 | print(f'native resize OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True) | ||
| 324 | except BaseException: | ||
| 325 | print('Native resize failure artifacts:', rig.root, file=sys.stderr) | ||
| 326 | for path in rig.root.glob('gui*.log'): | ||
| 327 | print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr) | ||
| 328 | raise | ||
| 329 | finally: | ||
| 330 | rig.close() | ||
| 331 | |||
| 332 | |||
| 333 | if __name__ == '__main__': | ||
| 334 | main() | ||