a73x

3eab2a35

feat: resize native pane dividers with mouse and keyboard

a73x   2026-09-05 09:46

Commit message
feat: resize native pane dividers with mouse and keyboard

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