a73x

efc99d58

refactor: separate native interaction policy from SDL handling

a73x   2026-09-05 15:02

Commit message
refactor: separate native interaction policy from SDL handling

RETRO.md
Old New
@@ -500,5 +500,84 @@ negative-banner, fragmented-line, reconnect, and shutdown checks.
500 rejection, the user explicitly authorized sharing ("Yes share"). The page is 500 rejection, the user explicitly authorized sharing ("Yes share"). The page is
501 published and HTTPS page/video range checks passed; existing routes are unchanged. 501 published and HTTPS page/video range checks passed; existing routes are unchanged.
502 Sharing approval does not imply demo acceptance. 502 Sharing approval does not imply demo acceptance.
503 - [ ] In the next authorized sprint, verify agent model metadata immediately after 503 - [x] In the next authorized sprint, verify agent model metadata immediately after
504 startup and avoid rerunning a socket-denied suite in the same sandbox. 504 startup and avoid rerunning a socket-denied suite in the same sandbox.
505 Completed at interaction-sprint startup; local socket checks used the known
506 authorized route directly.
507
508
509 ## Native interaction ownership — debt sprint, 2026-09-05
510
511 The user authorized this sprint after discussing accumulated native TUI debt.
512 Implementation and independent review are complete. All required validation passed, including the complete CI retry. The actual
513 GUI recording is retained. The
514 [delivery record](docs/superpowers/plans/2026-09-05-native-interaction-debt.md)
515 contains the commands, evidence and retained limits. Demo acceptance remains
516 pending; advancing here did not retroactively accept the prior SSH recording.
517
518 ### Results and cleanup
519
520 - `interaction.Controller` owns modes, held input and text suppression, menu
521 lifetimes, captured pending End, pointer resizing and intent saves. `frame.zig`
522 adapts SDL, measures geometry, synchronizes mouse capture and paints. Existing
523 controls, observation JSON, attachment identities and terminal layout remain
524 unchanged. Pane actions stays on prefix + p.
525 - Opening review needed no separate preparatory cleanup. Closing cleanup removed
526 the unused effects scaffold, shared the matching menu-key navigation mechanics,
527 and preserved j/k text in editors. Two direct controller regressions complement
528 the retained SDL tests; the reviewer found no unresolved correctness issue.
529 - `test/native_scale.py` promotes the previously temporary scale gate into the
530 repository, with explicit compositor endpoints, an owned headless output,
531 fixture cleanup and restoration of the original output scale.
532 - Native unit/integration, real old-daemon compatibility, five real OpenSSH
533 groups, Wayland 200%/100%/150%/200% transitions and raw-output stress passed.
534 Stress measured 105.7 MB over 30.3 seconds, with sampled input maxima of 55.4 ms
535 (shared daemon) and 55.5 ms (separate daemon), and frame p99 of 18.1 ms. These
536 are polling upper bounds on the recorded NVIDIA GPU compositor, not exact
537 latency or new macOS evidence.
538 - The initial full CI run timed out in the existing terminal host-picker test
539 waiting for `2> 1`. The unchanged isolated host group passed all 15 scenarios.
540 Its exact initial failure cause is unproven; the subsequent full CI run passed
541 without code or test changes. Both logs are retained.
542 - The retained 57-second actual GUI recording shows the final release with two
543 disclosed restart cuts and no audio. It demonstrates the unchanged interaction
544 behavior. The owned fixtures and compositor were stopped; no new Tailscale
545 payload was published.
546
547 ### Delegation and lessons
548
549 The reused agents' session metadata confirmed Luna and Terra immediately at
550 startup. Luna returned a types-only seam and then an unwired controller; that
551 was insufficient for the authorized deliverable. Root completed the coherent
552 controller migration, and Terra independently reviewed the integrated result.
553 Review caught physical-key identity, prefix-priority and text-suppression hazards
554 in the preliminary proposal and requested direct tests for the final boundary.
555 Agent agreement on a narrower cleanup would not have completed the user's task.
556
557 The retained `interaction-evidence/agent-metadata.json` contains cumulative
558 counters for these reused sessions, including the previous SSH sprint:
559
560 | Agent | Input tokens | Cached input (included) | Output tokens |
561 | --- | ---: | ---: | ---: |
562 | Luna implementer | 9,046,762 | 8,871,680 | 26,521 |
563 | Terra reviewer | 20,543,291 | 20,118,784 | 22,763 |
564
565 These are repeated-context counters, not unique context sizes or a monetary bill.
566 No cost saving is claimed. For another substantial extraction, start with a fresh
567 short task context and a concrete ownership/method contract. Stop an unwired
568 partial handoff promptly; avoid repeated review polling while implementation is
569 still being written.
570
571 ### Retained debt and next actions
572
573 - [ ] Record this recording's acceptance separately from the SSH trial and any
574 hands-on feedback; do not infer it from passing tests.
575 - [ ] The next painter/test-protocol change should assess moving observation hooks
576 out of `frame.zig`, preserving passive frame and state observations.
577 - [ ] A future recording-tooling package should promote the recorder/clip driver;
578 only the scale gate moved into the repository in this bounded sprint.
579 - [ ] If the terminal host-picker timeout recurs, capture its menu/discovery
580 ordering before adjusting waits. Its source path is `test/e2e_09_hosts.sh`.
581 - [x] Obtain authorization before the next production sprint. The user requested
582 another separation sprint aimed at independent daemon/GUI work, followed by
583 clearer code ownership. That authorization does not imply demo acceptance.
docs/native-sprint-workflow.md
Old New
@@ -59,6 +59,12 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
59 but does not establish hardware frame-time performance. Run performance checks 59 but does not establish hardware frame-time performance. Run performance checks
60 separately from recordings and other GUI fixtures on that compositor. 60 separately from recordings and other GUI fixtures on that compositor.
61 61
62 `test/native_scale.py MUX MUXG --output HEADLESS-1` runs the retained
63 200%/100%/150%/200% scale scenario, including drag cancellation, real PTY sizes
64 and framebuffer checks. Set `XDG_RUNTIME_DIR`, `WAYLAND_DISPLAY`, and `SWAYSOCK`
65 to an owned isolated Sway compositor and pass both release binaries. The script
66 restores the output's original scale and closes its fixtures on exit.
67
62 For responsiveness, run `make native-stress` on Linux. It builds both binaries 68 For responsiveness, run `make native-stress` on Linux. It builds both binaries
63 in ReleaseSafe from the same build graph and drives raw `cat /dev/random` in 69 in ReleaseSafe from the same build graph and drives raw `cat /dev/random` in
64 one pane while probing neighbours on the same daemon and a separate daemon. 70 one pane while probing neighbours on the same daemon and a separate daemon.
docs/superpowers/plans/2026-09-05-native-interaction-debt.md
Old New
@@ -0,0 +1,112 @@
1 # Native interaction ownership — debt sprint
2
3 The user authorized this sprint after the completed SSH follow-up. Sprint 5
4 remains accepted; the SSH recording's acceptance remains pending. Advancing to
5 this sprint does not imply acceptance of that recording.
6
7 Status: implementation, review and all required validation complete.
8 Recorded-demo acceptance is pending. The user authorized another separation
9 sprint before this recording was accepted; that authorization is not demo approval.
10
11 ## Scope and acceptance
12
13 Give native command modes, menu transitions, held input and captured pending End
14 actions a clear owner outside SDL window handling and painting. Preserve current
15 controls, labels, attachment identity rules, persistence and test-observation
16 schema. `Ctrl+\\`, then `p` remains Pane actions; Enter opens insertion, while
17 Choose session replaces the captured pane through the same picker.
18
19 Luna began the extraction but returned an unwired state-only seam. Root completed
20 the controller migration and focused regressions; Terra independently reviewed
21 the final controller, SDL adapter, shared navigation and tests. Root owns the
22 validation, delivery record and promoted `test/native_scale.py` fixture.
23 Workspace geometry, attachment runtime, persistence and SSH retry policy retain
24 their existing boundaries. A generic menu framework and terminal CLI redesign
25 are outside this sprint.
26
27 Opening assessment: no preparatory cleanup is needed. The substantial interaction
28 refactor is the deliverable itself. Luna and Terra were reused; their recorded
29 session `turn_context.model` values were checked immediately on resuming and
30 confirm `gpt-5.6-luna` and `gpt-5.6-terra` respectively.
31
32 Acceptance uses real multi-pane/host fixtures: insertion and replacement; held
33 keys and modal text; delayed End through command, focus, picker, resize, drag,
34 replacement and detach transitions; recovery and explicit Retry; resize and DPI
35 changes with independent PTY and framebuffer checks. Run the real SSH gate to
36 exercise recovery through the new controller. Preserve the terminal layout
37 sentinel. Record the actual GUI and keep demo approval distinct from automation.
38
39 ## Delivery evidence
40
41 The final change moves command/resize modes, held-key and text suppression,
42 picker/recovery lifetimes, captured pending End actions, pointer resizing and
43 intent saves into `src/gui/interaction.zig`. `frame.zig` translates SDL events,
44 measures geometry, synchronizes mouse capture and paints snapshots. A small shared
45 menu-key mapping preserves navigation aliases while leaving j/k as editor text.
46 The existing observation JSON and SDL regression scenarios remain intact.
47
48 Closing review found no remaining correctness issue. The preliminary unused
49 Effect API was removed. Two direct controller tests pin exact key release,
50 post-dismissal repeats, text suppression reset, command-to-resize, focus loss,
51 and modal precedence over the prefix. Existing SDL tests retain adapter coverage.
52 No workspace, runtime, transport or terminal-client behavior changed.
53
54 Final source was frozen before these commands, using the pinned compiler and
55 `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache`; local socket/PTY checks ran with the
56 required authorized permissions:
57
58 | Gate | Result |
59 | --- | --- |
60 | `deps/zig/zig build install native native-test -Doptimize=ReleaseSafe -p dist/native-interaction-release` | Passed; both release binaries from one build, 46 native test blocks |
61 | `make native-e2e` | Passed; 62 real-boundary scenarios |
62 | `native_picker.old_daemon` with `/tmp/muxg-sprint2-old-mux` | Passed separately; 63 total native integration scenarios |
63 | `test/native_ssh.py` with both release binaries, Wayland at 200% | All five real OpenSSH groups passed |
64 | `test/native_scale.py` with both release binaries and `--output HEADLESS-1` | Passed 200%/100%/150%/200%, drag cancellation, nested resize, PTYs and pixels |
65 | `make native-stress`, Wayland on isolated GPU compositor | Passed unchanged budgets |
66 | `make check` | Passed before commit |
67 | `make ci` | Full retry passed, including all e2e, agent and throughput gates |
68
69 The initial CI failure was `test/e2e_09_hosts.sh` waiting 15 seconds for the born
70 session's selected row (`2> 1`). The unchanged terminal host group passed all 15
71 scenarios when rerun with `E2E_ONLY=09_hosts make e2e`. The precise cause of that
72 first timeout is not established; its failure log is retained. The subsequent
73 full CI run passed without source or test changes.
74
75 Stress used a headless Sway output backed by NVIDIA GeForce RTX 3080, GLES2,
76 with no concurrent GUI fixture or recording. In 30.3 seconds the flooded daemon
77 read 105.7 MB. Sampled input-to-painted maxima were 55.4 ms on the shared daemon
78 and 55.5 ms on the separate daemon (94 samples each); final frame p99 was 18.1 ms.
79 The 5 ms polling interval and snapshot observation overhead make those input
80 numbers upper bounds, not exact input latency. These are Linux results; no new
81 macOS performance or compatibility claim is made.
82
83 The 57-second actual GUI recording uses the final release at 200%, H.264
84 1100×700 with no audio. It shows insertion cancellation, keyboard resize,
85 delayed shared-session End, Pane actions, close/reopen, unavailable-host return,
86 missing-session replacement, Detach and End. Three continuous clips have two
87 restart cuts. Input is injected through ordinary SDL events; this recording does
88 not claim a new compositor mouse-path trial. The owned compositor and fixtures
89 were stopped. Recording acceptance remains pending.
90
91 Retained local recording:
92 `/home/xanderle/.codex/visualizations/2026/09/05/01a071c2-5767-7790-a335-1a84536c9ca5/native-interaction-trial.mp4`.
93 Evidence is in the sibling `interaction-evidence/` directory: final logs, the
94 initial CI failure and targeted rerun, release/source hashes, stress JSON,
95 recording driver and model metadata. This recording has not been published to
96 Tailscale; prior demo routes retain their existing approved payloads.
97
98 ## Retained debt and next actions
99
100 - `frame.zig` still contains painting and test-observation hooks. They stay together
101 for this bounded ownership change; the next painter/test-protocol change should
102 assess extracting those hooks without changing passive observation semantics.
103 - The general screen recorder remains an artifact helper rather than a repository
104 tool. The scale gate is now durable. A future recording-tooling package owns
105 parameterizing capture, process cleanup and clip composition; no further
106 production refactor depends on it.
107 - Use a fresh task-specific agent context for the next substantial extraction.
108 Reused Luna stopped before wiring, and root completed implementation. Terra
109 provided useful concrete review. Recorded models and cumulative counters are
110 retained, with cached input separate; this sprint establishes no cost saving.
111 - Record this demo's approval separately from the earlier SSH demo and any
112 hands-on feedback. Start further production work only when authorized.
docs/superpowers/plans/2026-09-05-native-tiling.md
Old New
@@ -10,6 +10,8 @@ The retained SSH error-classification debt is addressed in the separately
10 authorized [follow-up sprint](2026-09-05-ssh-error-classification.md), whose 10 authorized [follow-up sprint](2026-09-05-ssh-error-classification.md), whose
11 implementation and validation are complete and recorded-demo acceptance is pending. 11 implementation and validation are complete and recorded-demo acceptance is pending.
12 12
13 The user subsequently authorized the [interaction ownership debt sprint](2026-09-05-native-interaction-debt.md), preserving the accepted controls and behavior.
14
13 ## Working agreement 15 ## Working agreement
14 16
15 Follow the reusable [sprint workflow](../../native-sprint-workflow.md), including 17 Follow the reusable [sprint workflow](../../native-sprint-workflow.md), including
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -8,6 +8,10 @@ to Sprint 5. Sprint 3 was accepted after its mouse-focused recording.
8 8
9 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). 9 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md).
10 10
11 The [interaction ownership refactor](../plans/2026-09-05-native-interaction-debt.md)
12 keeps these behaviors: SDL adapts events in `frame.zig`; `interaction.zig` owns
13 mode transitions, menus and pending actions.
14
11 ## Agreed requirements 15 ## Agreed requirements
12 16
13 - Sway-like directional splitting within a GUI window. Select horizontal or 17 - Sway-like directional splitting within a GUI window. Select horizontal or
@@ -128,7 +132,7 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI
128 | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits | 132 | `r` | Enter resize mode; `h/j/k/l` or arrows resize, Esc/Enter exits |
129 | `d` | Detach and remove the focused pane | 133 | `d` | Detach and remove the focused pane |
130 | `x` | End the focused session through the daemon's existing end workflow | 134 | `x` | End the focused session through the daemon's existing end workflow |
131 | `p` | Open recovery actions for the focused pane | 135 | `p` | Open Pane actions (Retry, Choose session, Detach) for the focused pane |
132 | Esc | Cancel the command and pending split | 136 | Esc | Cancel the command and pending split |
133 137
134 The split and movement bindings above were agreed on 2026-09-05: reserve 138 The split and movement bindings above were agreed on 2026-09-05: reserve
src/gui/frame.zig
Old New
@@ -7,6 +7,7 @@ const model = @import("workspace.zig");
7 const runtime = @import("runtime.zig"); 7 const runtime = @import("runtime.zig");
8 const picker_mod = @import("picker.zig"); 8 const picker_mod = @import("picker.zig");
9 const persistence = @import("persistence.zig"); 9 const persistence = @import("persistence.zig");
10 const interaction = @import("interaction.zig");
10 const font = @import("font.zig"); 11 const font = @import("font.zig");
11 const atlas = @import("atlas.zig"); 12 const atlas = @import("atlas.zig");
12 const quads = @import("quads.zig"); 13 const quads = @import("quads.zig");
@@ -294,461 +295,81 @@ fn sdlFail(op: []const u8) u8 {
294 295
295 /// This state belongs to the window thread. Geometry and input select stable 296 /// This state belongs to the window thread. Geometry and input select stable
296 /// pane IDs; transport ownership lives in Runtime and rendering uses snapshots. 297 /// pane IDs; transport ownership lives in Runtime and rendering uses snapshots.
297 const Recovery = struct { 298 const Recovery = interaction.Recovery;
298 kind: enum { recovery, ending, force_end },
299 key: model.Attachment,
300 selected: usize = 0,
301 notice: [1024]u8 = @splat(0),
302 notice_len: usize = 0,
303 fn count(self: Recovery) usize {
304 return switch (self.kind) {
305 .recovery => 3,
306 .ending => 1,
307 .force_end => 2,
308 };
309 }
310 fn label(self: Recovery, index: usize) []const u8 {
311 return switch (self.kind) {
312 .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index],
313 .ending => "Dismiss (End is already requested)",
314 .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index],
315 };
316 }
317 fn title(self: Recovery) []const u8 {
318 return switch (self.kind) {
319 .recovery => "Pane actions",
320 .ending => "Ending session...",
321 .force_end => "Other clients are attached",
322 };
323 }
324 fn setNotice(self: *Recovery, text: []const u8) void {
325 self.notice_len = @min(text.len, self.notice.len);
326 @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]);
327 }
328 fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) picker_mod.View {
329 const w = @min(width, @as(u32, metrics.cell_w) * 74);
330 const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4)));
331 return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h };
332 }
333 };
334 const Events = struct { 299 const Events = struct {
335 rt: *runtime.Runtime, 300 ui: interaction.Controller,
336 win: *c.SDL_Window, 301 win: *c.SDL_Window,
337 wake: *Wake, 302 wake: *Wake,
338 hook: ?*HookReader, 303 hook: ?*HookReader,
339 cache: *font.GlyphCache, 304 cache: *font.GlyphCache,
340 base_font_px: u16, 305 base_font_px: u16,
341 picker: ?*picker_mod.Picker = null,
342 next_request: u64 = 1,
343 key_path: ?[]const u8 = null,
344 local_target: ?client.Target = null,
345 store: ?*persistence.Store = null,
346 intent_dirty: bool = false,
347 save_notice: [256]u8 = @splat(0),
348 save_notice_len: usize = 0,
349 recovery: ?Recovery = null,
350 pending_end: ?struct { key: model.Attachment, request: u64 } = null,
351 notice_owned: [1024]u8 = @splat(0),
352 layout: model.Layout = .{},
353 metrics: model.Metrics,
354 fb_w: c_int,
355 fb_h: c_int,
356 dirty: bool = true,
357 geometry_dirty: bool = false, 306 geometry_dirty: bool = false,
358 suppress_text: bool = false,
359 command_mode: bool = false,
360 resize_mode: bool = false,
361 modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty,
362 logical_w: c_int = 0, 307 logical_w: c_int = 0,
363 logical_h: c_int = 0, 308 logical_h: c_int = 0,
364 drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, 309 captured: bool = false,
365 consumed_key: ?u32 = null,
366 notice: []const u8 = "",
367 310
368 fn deinit(self: *Events) void { 311 fn deinit(self: *Events) void {
369 if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{}); 312 self.ui.deinit();
370 self.cancelDrag(); 313 self.syncCapture();
371 self.modal_held.deinit(self.rt.alloc);
372 if (self.picker) |picker| picker.deinit();
373 } 314 }
374 fn command(self: *Events, key: u32) !void { 315 fn syncCapture(self: *Events) void {
375 self.cancelDrag(); 316 const capturing = self.ui.drag != null;
376 self.command_mode = false; 317 if (self.captured != capturing) {
377 self.notice = ""; 318 _ = c.SDL_CaptureMouse(capturing);
378 const ws = &self.rt.workspace; 319 self.captured = capturing;
379 if (keyDirection(key)) |direction| {
380 const before = ws.tab().focus;
381 ws.moveFocus(&self.layout, direction);
382 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
383 } else switch (key) {
384 c.SDLK_V => ws.arm(.beside),
385 c.SDLK_B => ws.arm(.stacked),
386 c.SDLK_R => self.resize_mode = true,
387 c.SDLK_D => if (ws.tab().focus) |id| try self.detach(id),
388 c.SDLK_X => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
389 c.SDLK_P => if (ws.tab().focus) |id| {
390 self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key };
391 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
392 },
393 c.SDLK_ESCAPE => ws.cancel(),
394 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
395 self.openPicker(.insert) catch |err| {
396 self.notice = @errorName(err);
397 self.dirty = true;
398 return;
399 };
400 },
401 else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add",
402 } 320 }
403 self.dirty = true;
404 } 321 }
405
406 /// Commit events sample the actual drawable even if its resize notice 322 /// Commit events sample the actual drawable even if its resize notice
407 /// is still behind this event in SDL's bounded queue. 323 /// is still behind this event in SDL's bounded queue.
408 fn dispatch(self: *Events, ev: c.SDL_Event) !bool { 324 fn dispatch(self: *Events, ev: c.SDL_Event) !bool {
409 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))) { 325 if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.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.ui.drag != null and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) {
410 self.geometry_dirty = true; 326 self.geometry_dirty = true;
411 try self.refreshGeometry(); 327 try self.refreshGeometry();
412 } 328 }
413 return self.handle(ev); 329 return self.handle(ev);
414 } 330 }
415 fn handle(self: *Events, ev: c.SDL_Event) !bool { 331 fn handle(self: *Events, ev: c.SDL_Event) !bool {
416 std.debug.assert(self.picker == null or self.recovery == null); 332 defer self.syncCapture();
333 std.debug.assert(self.ui.picker == null or self.ui.recovery == null);
417 switch (ev.type) { 334 switch (ev.type) {
418 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, 335 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
419 c.SDL_EVENT_TEXT_INPUT => { 336 c.SDL_EVENT_TEXT_INPUT => {
420 defer if (self.hook) |h| h.releaseText(ev.text.text); 337 defer if (self.hook) |h| h.releaseText(ev.text.text);
421 if (!self.suppress_text) { 338 try self.ui.textInput(std.mem.span(ev.text.text));
422 if (self.picker) |picker| {
423 try picker.text(std.mem.span(ev.text.text));
424 self.dirty = true;
425 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) try self.rt.input(std.mem.span(ev.text.text));
426 }
427 self.suppress_text = false;
428 },
429 c.SDL_EVENT_KEY_DOWN => {
430 self.suppress_text = false;
431 const key = ev.key.key;
432 if (!self.resize_mode and self.recovery == null and self.picker == null and self.modal_held.contains(key)) {
433 self.suppress_text = true;
434 return true;
435 }
436 if (self.consumed_key == key) {
437 self.suppress_text = true;
438 return true;
439 }
440 if (self.recovery) |*menu| {
441 try self.modal_held.put(self.rt.alloc, key, {});
442 self.suppress_text = true;
443 switch (key) {
444 c.SDLK_UP, c.SDLK_K => menu.selected -|= 1,
445 c.SDLK_DOWN, c.SDLK_J => menu.selected = @min(menu.selected + 1, menu.count() - 1),
446 c.SDLK_ESCAPE => {
447 self.consumed_key = key;
448 self.recovery = null;
449 },
450 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
451 self.consumed_key = key;
452 try self.recoveryAction();
453 },
454 else => {},
455 }
456 self.dirty = true;
457 return true;
458 }
459 if (self.resize_mode) {
460 self.cancelDrag();
461 try self.modal_held.put(self.rt.alloc, key, {});
462 self.suppress_text = true;
463 if (key == c.SDLK_ESCAPE or key == c.SDLK_RETURN or key == c.SDLK_KP_ENTER) {
464 self.resize_mode = false;
465 self.consumed_key = key;
466 self.notice = "";
467 } else if (keyDirection(key)) |direction| {
468 if (!self.layout.fits) {
469 self.notice = "Window too small to resize";
470 } else {
471 self.notice = "";
472 if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
473 try self.relayout();
474 self.intent_dirty = true;
475 }
476 }
477 }
478 self.dirty = true;
479 return true;
480 }
481 if (self.picker) |picker| {
482 try self.modal_held.put(self.rt.alloc, key, {});
483 const mapped: ?picker_mod.Key = switch (key) {
484 c.SDLK_UP => .up,
485 c.SDLK_DOWN => .down,
486 c.SDLK_K => if (picker.level == .hosts or picker.level == .sessions) .up else null,
487 c.SDLK_J => if (picker.level == .hosts or picker.level == .sessions) .down else null,
488 c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter,
489 c.SDLK_ESCAPE => .escape,
490 c.SDLK_BACKSPACE => .backspace,
491 else => null,
492 };
493 if (mapped) |k| {
494 self.suppress_text = true;
495 if (k == .enter or k == .escape) self.consumed_key = key;
496 try picker.key(k);
497 } else if (ev.key.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0) self.suppress_text = true;
498 self.dirty = true;
499 try self.finishPicker();
500 return true;
501 }
502 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;
503 if (prefix) {
504 self.cancelDrag();
505 self.suppress_text = true;
506 if (!ev.key.repeat) {
507 if (self.command_mode) {
508 self.command_mode = false;
509 try self.sendKey(keyEvent(key, ev.key.mod).?);
510 } else self.command_mode = true;
511 self.notice = "";
512 self.dirty = true;
513 }
514 } else if (self.command_mode) {
515 self.consumed_key = key;
516 self.suppress_text = true;
517 try self.command(key);
518 } else if (key == c.SDLK_ESCAPE and self.rt.workspace.tab().pending != null) {
519 self.rt.workspace.cancel();
520 self.dirty = true;
521 } else {
522 const translated = if (ev.key.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.key.scancode, ev.key.mod, false) else key;
523 if (keyEvent(translated, ev.key.mod)) |mapped| {
524 try self.sendKey(mapped);
525 self.suppress_text = mapped.key == .char;
526 }
527 }
528 },
529 c.SDL_EVENT_KEY_UP => {
530 _ = self.modal_held.remove(ev.key.key);
531 if (self.consumed_key == ev.key.key) self.consumed_key = null;
532 self.suppress_text = false;
533 },
534 c.SDL_EVENT_WINDOW_FOCUS_LOST => {
535 self.suppress_text = false;
536 self.command_mode = false;
537 self.resize_mode = false;
538 self.modal_held.clearRetainingCapacity();
539 self.cancelDrag();
540 self.consumed_key = null;
541 self.dirty = true;
542 }, 339 },
340 c.SDL_EVENT_KEY_DOWN => try self.ui.keyDown(interactionKey(ev.key)),
341 c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key),
342 c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(),
543 c.SDL_EVENT_MOUSE_BUTTON_DOWN => if (ev.button.button == c.SDL_BUTTON_LEFT) { 343 c.SDL_EVENT_MOUSE_BUTTON_DOWN => if (ev.button.button == c.SDL_BUTTON_LEFT) {
544 var w: c_int = 0; 344 var w: c_int = 0;
545 var h: c_int = 0; 345 var h: c_int = 0;
546 if (c.SDL_GetWindowSize(self.win, &w, &h)) { 346 if (c.SDL_GetWindowSize(self.win, &w, &h)) {
547 const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h); 347 const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h);
548 try self.pointerDown(at.x, at.y, grabPixels(w, self.fb_w), grabPixels(h, self.fb_h)); 348 try self.ui.pointerDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h));
549 if (self.drag != null) _ = c.SDL_CaptureMouse(true);
550 } 349 }
551 }, 350 },
552 c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { 351 c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => {
553 if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; 352 if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true;
554 if (self.drag != null) { 353 if (self.ui.drag != null) {
555 var w: c_int = 0; 354 var w: c_int = 0;
556 var h: c_int = 0; 355 var h: c_int = 0;
557 if (c.SDL_GetWindowSize(self.win, &w, &h)) { 356 if (c.SDL_GetWindowSize(self.win, &w, &h)) {
558 const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; 357 const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION;
559 const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.fb_w); 358 const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w);
560 const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.fb_h); 359 const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h);
561 if (x != null and y != null) try self.pointerMove(x.?, y.?); 360 if (x != null and y != null) try self.ui.pointerMove(x.?, y.?);
562 } 361 }
563 } 362 }
564 if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.cancelDrag(); 363 if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.ui.cancelDrag();
565 }, 364 },
566 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, 365 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true,
567 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, 366 c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true,
568 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) }))) { 367 else => if (ev.type == self.wake.event_type and (ev.user.code == 1 or self.ui.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) }))) {
569 self.dirty = true; 368 self.ui.dirty = true;
570 }, 369 },
571 } 370 }
572 return true; 371 return true;
573 } 372 }
574 fn finishPicker(self: *Events) !void {
575 if (self.picker) |picker| if (picker.closed) {
576 const inserted = picker.inserted;
577 picker.deinit();
578 self.picker = null;
579 if (inserted) {
580 try self.relayout();
581 self.intent_dirty = true;
582 }
583 self.dirty = true;
584 };
585 }
586 fn openPicker(self: *Events, mode: picker_mod.Picker.Mode) !void {
587 self.cancelDrag();
588 self.recovery = null;
589 self.notice = "";
590 const picker = try picker_mod.Picker.initMode(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, mode);
591 errdefer picker.deinit();
592 if (picker.origin == null) if (self.local_target) |target| try picker.includeTarget(target);
593 self.picker = picker;
594 self.dirty = true;
595 }
596 fn setNotice(self: *Events, text: []const u8) void {
597 const n = @min(text.len, self.notice_owned.len);
598 @memcpy(self.notice_owned[0..n], text[0..n]);
599 self.notice = self.notice_owned[0..n];
600 self.dirty = true;
601 }
602 fn detach(self: *Events, id: model.PaneId) !void {
603 self.cancelDrag();
604 self.resize_mode = false;
605 if (self.pending_end) |pending| if (pending.key.pane == id) {
606 self.pending_end = null;
607 self.setNotice("Pane detached; the pending End outcome is unknown");
608 };
609 self.recovery = null;
610 self.rt.remove(id);
611 try self.relayout();
612 self.intent_dirty = true;
613 }
614 fn beginEnd(self: *Events, key: model.Attachment, force: bool) !void {
615 if (!self.rt.accepts(key)) return;
616 if (self.pending_end != null) {
617 self.setNotice("An End request is still pending");
618 return;
619 }
620 const request = self.next_request;
621 self.next_request += 1;
622 self.rt.get(key.pane).?.pump.say(.{ .end = .{ .request = request, .force = force } }) catch |err| {
623 self.setNotice(@errorName(err));
624 self.recovery = null;
625 return;
626 };
627 self.pending_end = .{ .key = key, .request = request };
628 self.recovery = .{ .kind = .ending, .key = key };
629 self.dirty = true;
630 }
631 fn pollEnd(self: *Events) !void {
632 const pending = self.pending_end orelse return;
633 if (!self.rt.accepts(pending.key)) {
634 self.pending_end = null;
635 self.setNotice("Pane changed; the earlier End outcome is unknown");
636 return;
637 }
638 const result = self.rt.get(pending.key.pane).?.status.ending;
639 if (result.request != pending.request or result.phase == .pending) return;
640 self.pending_end = null;
641 const own_menu = if (self.recovery) |menu| menu.kind == .ending and std.meta.eql(menu.key, pending.key) else false;
642 if (own_menu) self.recovery = null;
643 switch (result.phase) {
644 .accepted => try self.detach(pending.key.pane),
645 .refused => {
646 self.setNotice(result.reasonText());
647 if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.picker == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) {
648 self.recovery = .{ .kind = .force_end, .key = pending.key };
649 self.recovery.?.setNotice(result.reasonText());
650 }
651 },
652 .unknown => self.setNotice(result.reasonText()),
653 else => {},
654 }
655 self.dirty = true;
656 }
657 fn recoveryAction(self: *Events) !void {
658 const menu = self.recovery orelse return;
659 self.recovery = null;
660 self.dirty = true;
661 if (!self.rt.accepts(menu.key)) {
662 self.setNotice("The pane changed");
663 return;
664 }
665 switch (menu.kind) {
666 .ending => self.setNotice("End requested; waiting for its outcome"),
667 .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true),
668 .recovery => switch (menu.selected) {
669 0, 1 => {
670 if (self.pending_end != null) {
671 self.setNotice("Wait for the pending End outcome before replacing the attachment");
672 return;
673 }
674 if (menu.selected == 0) {
675 self.rt.retry(menu.key.pane, self.layout.get(menu.key.pane).?) catch |err| {
676 self.setNotice(@errorName(err));
677 return;
678 };
679 self.intent_dirty = true;
680 } else {
681 _ = self.rt.workspace.focus(menu.key.pane);
682 try self.openPicker(.replace);
683 }
684 },
685 2 => try self.detach(menu.key.pane),
686 else => unreachable,
687 },
688 }
689 }
690 fn saveIntent(self: *Events) void {
691 if (!self.intent_dirty or self.drag != null) return;
692 self.intent_dirty = false;
693 const store = self.store orelse return;
694 store.save(&self.rt.workspace) catch |err| {
695 const text = std.fmt.bufPrint(&self.save_notice, "Workspace not saved: {s}", .{@errorName(err)}) catch unreachable;
696 self.save_notice_len = text.len;
697 self.dirty = true;
698 return;
699 };
700 self.save_notice_len = 0;
701 }
702 fn pointerDown(self: *Events, x: u32, y: u32, grab_x: u32, grab_y: u32) !void {
703 self.cancelDrag();
704 if (self.recovery) |menu| {
705 const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
706 for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) {
707 self.recovery.?.selected = i;
708 try self.recoveryAction();
709 break;
710 };
711 } else if (self.picker) |picker| {
712 try picker.click(x, y);
713 try self.finishPicker();
714 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
715 const d = self.layout.divider(id).?;
716 const position = if (d.direction == .beside) x else y;
717 self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() };
718 } else if (!self.resize_mode) {
719 if (self.layout.hit(x, y)) |id| {
720 self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id;
721 _ = self.rt.workspace.focus(id);
722 } else if (self.layout.len == 0) try self.openPicker(.insert);
723 }
724 self.dirty = true;
725 }
726 fn pointerMove(self: *Events, x: i64, y: i64) !void {
727 const drag = self.drag orelse return;
728 if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag();
729 const d = self.layout.divider(drag.id) orelse return self.cancelDrag();
730 const position = (if (d.direction == .beside) x else y) - drag.offset;
731 if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
732 self.drag.?.changed = true;
733 try self.relayout();
734 }
735 }
736 fn cancelDrag(self: *Events) void {
737 if (self.drag == null) return;
738 self.intent_dirty = self.intent_dirty or self.drag.?.changed;
739 self.drag = null;
740 _ = c.SDL_CaptureMouse(false);
741 }
742 fn sendKey(self: *Events, key: keymap.Event) !void {
743 var buf: [keymap.max_seq_len]u8 = undefined;
744 const bytes = keymap.encode(key, &buf);
745 if (bytes.len != 0) try self.rt.input(bytes);
746 }
747 fn relayout(self: *Events) !void {
748 self.layout = self.rt.workspace.layout(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
749 try self.rt.resize(&self.layout);
750 self.dirty = true;
751 }
752 fn refreshGeometry(self: *Events) !void { 373 fn refreshGeometry(self: *Events) !void {
753 if (!self.geometry_dirty) return; 374 if (!self.geometry_dirty) return;
754 var w: c_int = 0; 375 var w: c_int = 0;
@@ -757,24 +378,16 @@ const Events = struct {
757 var logical_w: c_int = 0; 378 var logical_w: c_int = 0;
758 var logical_h: c_int = 0; 379 var logical_h: c_int = 0;
759 if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; 380 if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed;
760 if (logical_w != self.logical_w or logical_h != self.logical_h) self.cancelDrag(); 381 if (logical_w != self.logical_w or logical_h != self.logical_h) self.ui.cancelDrag();
761 self.logical_w = logical_w; 382 self.logical_w = logical_w;
762 self.logical_h = logical_h; 383 self.logical_h = logical_h;
763 try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); 384 try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win));
764 } 385 }
765 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void { 386 fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void {
387 defer self.syncCapture();
766 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale)); 388 _ = try self.cache.setPixelSize(font.scaledPixels(self.base_font_px, scale));
767 const metrics = measuredMetrics(self.cache.face, scale); 389 const metrics = measuredMetrics(self.cache.face, scale);
768 if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) self.cancelDrag(); 390 try self.ui.updateGeometry(w, h, metrics);
769 self.fb_w = w;
770 self.fb_h = h;
771 self.metrics = metrics;
772 if (self.picker) |picker| {
773 picker.width = @intCast(@max(w, 0));
774 picker.height = @intCast(@max(h, 0));
775 picker.metrics = self.metrics;
776 }
777 try self.relayout();
778 self.geometry_dirty = false; 391 self.geometry_dirty = false;
779 } 392 }
780 }; 393 };
@@ -796,13 +409,36 @@ fn grabPixels(logical: c_int, pixels: c_int) u32 {
796 if (logical <= 0 or pixels <= 0) return 0; 409 if (logical <= 0 or pixels <= 0) return 0;
797 return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical))); 410 return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical)));
798 } 411 }
799 fn keyDirection(key: u32) ?model.Neighbor { 412 fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
800 return switch (key) { 413 const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key;
801 c.SDLK_H, c.SDLK_LEFT => .left, 414 const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
802 c.SDLK_J, c.SDLK_DOWN => .down, 415 return .{
803 c.SDLK_K, c.SDLK_UP => .up, 416 .code = ev.key,
804 c.SDLK_L, c.SDLK_RIGHT => .right, 417 .kind = switch (ev.key) {
805 else => null, 418 c.SDLK_V => .v,
419 c.SDLK_B => .b,
420 c.SDLK_R => .r,
421 c.SDLK_D => .d,
422 c.SDLK_X => .x,
423 c.SDLK_P => .p,
424 c.SDLK_ESCAPE => .escape,
425 c.SDLK_RETURN => .enter,
426 c.SDLK_KP_ENTER => .keypad_enter,
427 c.SDLK_UP => .up,
428 c.SDLK_K => .k,
429 c.SDLK_DOWN => .down,
430 c.SDLK_J => .j,
431 c.SDLK_LEFT => .left,
432 c.SDLK_H => .h,
433 c.SDLK_RIGHT => .right,
434 c.SDLK_L => .l,
435 c.SDLK_BACKSPACE => .backspace,
436 else => .other,
437 },
438 .prefix = prefix,
439 .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0,
440 .repeat = ev.repeat,
441 .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod),
806 }; 442 };
807 } 443 }
808 444
@@ -881,10 +517,10 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
881 }; 517 };
882 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 518 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
883 defer if (hook) |*h| h.deinit(); 519 defer if (hook) |*h| h.deinit();
884 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, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len }; 520 var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } };
885 defer events.deinit(); 521 defer events.deinit();
886 try events.relayout(); 522 try events.ui.relayout();
887 if (events.layout.len == 0) try events.openPicker(.insert); 523 if (events.ui.layout.len == 0) try events.ui.openPicker(.insert);
888 var headers: [model.max_panes]Header = @splat(.{}); 524 var headers: [model.max_panes]Header = @splat(.{});
889 var popup: PopupFrame = .{}; 525 var popup: PopupFrame = .{};
890 var popup_lists: quads.Lists = .{}; 526 var popup_lists: quads.Lists = .{};
@@ -896,10 +532,10 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
896 while (true) { 532 while (true) {
897 if (hook) |*h| { 533 if (hook) |*h| {
898 try h.read(win); 534 try h.read(win);
899 if (h.capture != null) events.dirty = true; 535 if (h.capture != null) events.ui.dirty = true;
900 } 536 }
901 var ev: c.SDL_Event = undefined; 537 var ev: c.SDL_Event = undefined;
902 const wait_ms: c_int = if (events.dirty) 0 else if (hook != null) 16 else 100; 538 const wait_ms: c_int = if (events.ui.dirty) 0 else if (hook != null) 16 else 100;
903 if (c.SDL_WaitEventTimeout(&ev, wait_ms)) { 539 if (c.SDL_WaitEventTimeout(&ev, wait_ms)) {
904 if (!try events.dispatch(ev)) return 0; 540 if (!try events.dispatch(ev)) return 0;
905 var consumed: usize = 1; 541 var consumed: usize = 1;
@@ -910,15 +546,16 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
910 try events.refreshGeometry(); 546 try events.refreshGeometry();
911 if (usr1_seen.swap(false, .acq_rel)) report(&ring); 547 if (usr1_seen.swap(false, .acq_rel)) report(&ring);
912 const now = std.time.milliTimestamp(); 548 const now = std.time.milliTimestamp();
913 events.dirty = rt.poll(now) or events.dirty; 549 events.ui.dirty = rt.poll(now) or events.ui.dirty;
914 try events.pollEnd(); 550 try events.ui.pollEnd();
915 if (events.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { 551 events.syncCapture();
552 if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) {
916 events.geometry_dirty = true; 553 events.geometry_dirty = true;
917 try events.refreshGeometry(); 554 try events.refreshGeometry();
918 events.dirty = (try picker.poll()) or events.dirty; 555 events.ui.dirty = (try picker.poll()) or events.ui.dirty;
919 }; 556 };
920 try events.finishPicker(); 557 try events.ui.finishPicker();
921 events.saveIntent(); 558 events.ui.saveIntent();
922 if (hook) |*h| if (h.state) |path| { 559 if (hook) |*h| if (h.state) |path| {
923 defer alloc.free(path); 560 defer alloc.free(path);
924 h.state = null; 561 h.state = null;
@@ -932,10 +569,10 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
932 if (visible_blink and now >= blink_until) { 569 if (visible_blink and now >= blink_until) {
933 blink_phase = !blink_phase; 570 blink_phase = !blink_phase;
934 blink_until = now + 500; 571 blink_until = now + 500;
935 events.dirty = true; 572 events.ui.dirty = true;
936 } 573 }
937 if (!events.dirty or events.fb_w <= 0 or events.fb_h <= 0) continue; 574 if (!events.ui.dirty or events.ui.fb_w <= 0 or events.ui.fb_h <= 0) continue;
938 events.dirty = false; 575 events.ui.dirty = false;
939 var timer = try std.time.Timer.start(); 576 var timer = try std.time.Timer.start();
940 var timing: bench.Frame = .{}; 577 var timing: bench.Frame = .{};
941 instances.clearRetainingCapacity(); 578 instances.clearRetainingCapacity();
@@ -943,7 +580,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
943 lists.foregrounds.clearRetainingCapacity(); 580 lists.foregrounds.clearRetainingCapacity();
944 // Freeze each pane under its own mutex. No transport locks are held 581 // Freeze each pane under its own mutex. No transport locks are held
945 // during shaping, atlas growth, instance generation, or GL calls. 582 // during shaping, atlas growth, instance generation, or GL calls.
946 for (events.layout.items(), 0..) |p, i| { 583 for (events.ui.layout.items(), 0..) |p, i| {
947 const live = rt.get(p.id).?; 584 const live = rt.get(p.id).?;
948 if (p.visible.w == 0 or p.visible.h == 0) continue; 585 if (p.visible.w == 0 or p.visible.h == 0) continue;
949 timing.apply_us = @max(timing.apply_us, try live.capture(p.cols, p.rows)); 586 timing.apply_us = @max(timing.apply_us, try live.capture(p.cols, p.rows));
@@ -951,18 +588,18 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
951 } 588 }
952 // Prepare ALL pane and header glyphs before normalizing ANY UV. A 589 // Prepare ALL pane and header glyphs before normalizing ANY UV. A
953 // later pane may grow the shared atlas and change every earlier UV. 590 // later pane may grow the shared atlas and change every earlier UV.
954 for (events.layout.items(), 0..) |p, i| { 591 for (events.ui.layout.items(), 0..) |p, i| {
955 if (p.visible.w == 0 or p.visible.h == 0) continue; 592 if (p.visible.w == 0 or p.visible.h == 0) continue;
956 try prepareGrid(&cache, rt.get(p.id).?.snapshot); 593 try prepareGrid(&cache, rt.get(p.id).?.snapshot);
957 var header_row = headers[i].row(); 594 var header_row = headers[i].row();
958 try prepareRow(&cache, &header_row); 595 try prepareRow(&cache, &header_row);
959 } 596 }
960 popup.len = 0; 597 popup.len = 0;
961 if (events.picker) |picker| { 598 if (events.ui.picker) |picker| {
962 popup.set(picker); 599 popup.set(picker);
963 } else if (events.recovery) |menu| popup.setRecovery(menu, &events) else if (events.layout.len == 0) popup.setEmpty(&events); 600 } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events);
964 if (events.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.save_notice[0..events.save_notice_len], popup.rect.w / events.metrics.cell_w); 601 if (events.ui.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.save_notice[0..events.ui.save_notice_len], popup.rect.w / events.ui.metrics.cell_w);
965 if (events.picker != null and events.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.notice, popup.rect.w / events.metrics.cell_w); 602 if (events.ui.picker != null and events.ui.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.notice, popup.rect.w / events.ui.metrics.cell_w);
966 for (popup.lines[0..popup.len]) |*line| { 603 for (popup.lines[0..popup.len]) |*line| {
967 var row = line.row(); 604 var row = line.row();
968 try prepareRow(&cache, &row); 605 try prepareRow(&cache, &row);
@@ -970,7 +607,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
970 const had_blink = visible_blink; 607 const had_blink = visible_blink;
971 visible_blink = false; 608 visible_blink = false;
972 const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase }; 609 const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase };
973 for (events.layout.items(), 0..) |p, i| { 610 for (events.ui.layout.items(), 0..) |p, i| {
974 if (p.visible.w == 0 or p.visible.h == 0) continue; 611 if (p.visible.w == 0 or p.visible.h == 0) continue;
975 const live = rt.get(p.id).?; 612 const live = rt.get(p.id).?;
976 const grid = live.snapshot; 613 const grid = live.snapshot;
@@ -1008,26 +645,26 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
1008 timing.atlas_us = bench.usSince(&timer); 645 timing.atlas_us = bench.usSince(&timer);
1009 renderer.uploadInstances(instances.items); 646 renderer.uploadInstances(instances.items);
1010 timing.upload_us = bench.usSince(&timer); 647 timing.upload_us = bench.usSince(&timer);
1011 renderer.draw(instances.items.len, events.fb_w, events.fb_h, 0x687888ff); 648 renderer.draw(instances.items.len, events.ui.fb_w, events.ui.fb_h, 0x687888ff);
1012 if (hook) |*h| if (h.capture != null or h.retain_frame) { 649 if (hook) |*h| if (h.capture != null or h.retain_frame) {
1013 const pixels = try renderer.readPixels(alloc, @intCast(events.fb_w), @intCast(events.fb_h)); 650 const pixels = try renderer.readPixels(alloc, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h));
1014 var owned = true; 651 var owned = true;
1015 defer if (owned) alloc.free(pixels); 652 defer if (owned) alloc.free(pixels);
1016 if (h.capture) |path| { 653 if (h.capture) |path| {
1017 defer alloc.free(path); 654 defer alloc.free(path);
1018 h.capture = null; 655 h.capture = null;
1019 try writePixels(alloc, path, @intCast(events.fb_w), @intCast(events.fb_h), pixels); 656 try writePixels(alloc, path, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h), pixels);
1020 } 657 }
1021 if (h.retain_frame) { 658 if (h.retain_frame) {
1022 alloc.free(h.pixels); 659 alloc.free(h.pixels);
1023 h.pixels = pixels; 660 h.pixels = pixels;
1024 h.pixel_w = @intCast(events.fb_w); 661 h.pixel_w = @intCast(events.ui.fb_w);
1025 h.pixel_h = @intCast(events.fb_h); 662 h.pixel_h = @intCast(events.ui.fb_h);
1026 owned = false; 663 owned = false;
1027 } 664 }
1028 }; 665 };
1029 if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow"); 666 if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow");
1030 for (events.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) { 667 for (events.ui.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) {
1031 const live = rt.get(p.id).?; 668 const live = rt.get(p.id).?;
1032 live.painted_seq = live.snapshot_seq; 669 live.painted_seq = live.snapshot_seq;
1033 }; 670 };
@@ -1067,19 +704,19 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
1067 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; 704 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
1068 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics); 705 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
1069 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 706 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1070 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 }; 707 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } };
1071 defer events.deinit(); 708 defer events.deinit();
1072 try events.relayout(); 709 try events.ui.relayout();
1073 events.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; 710 events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 };
1074 try events.openPicker(.insert); 711 try events.ui.openPicker(.insert);
1075 rt.get(id).?.pump.mu.lock(); 712 rt.get(id).?.pump.mu.lock();
1076 rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; 713 rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
1077 rt.get(id).?.pump.mu.unlock(); 714 rt.get(id).?.pump.mu.unlock();
1078 _ = rt.poll(std.time.milliTimestamp()); 715 _ = rt.poll(std.time.milliTimestamp());
1079 try events.pollEnd(); 716 try events.ui.pollEnd();
1080 try std.testing.expect(events.recovery == null and events.picker != null); 717 try std.testing.expect(events.ui.recovery == null and events.ui.picker != null);
1081 try std.testing.expect(events.pending_end == null); 718 try std.testing.expect(events.ui.pending_end == null);
1082 events.picker.?.level = .session_name; 719 events.ui.picker.?.level = .session_name;
1083 var ev = std.mem.zeroes(c.SDL_Event); 720 var ev = std.mem.zeroes(c.SDL_Event);
1084 ev.key.type = c.SDL_EVENT_KEY_DOWN; 721 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1085 ev.key.key = c.SDLK_X; 722 ev.key.key = c.SDLK_X;
@@ -1087,7 +724,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
1087 ev.text.type = c.SDL_EVENT_TEXT_INPUT; 724 ev.text.type = c.SDL_EVENT_TEXT_INPUT;
1088 ev.text.text = "x"; 725 ev.text.text = "x";
1089 _ = try events.handle(ev); 726 _ = try events.handle(ev);
1090 try std.testing.expectEqualStrings("x", events.picker.?.input.items); 727 try std.testing.expectEqualStrings("x", events.ui.picker.?.input.items);
1091 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request); 728 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request);
1092 } 729 }
1093 730
@@ -1100,25 +737,25 @@ test "delayed End refusal respects changed focus and recovery context names its
1100 const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics); 737 const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics);
1101 const key = rt.get(first).?.key; 738 const key = rt.get(first).?.key;
1102 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 739 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1103 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 }; 740 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } };
1104 defer events.deinit(); 741 defer events.deinit();
1105 try events.relayout(); 742 try events.ui.relayout();
1106 events.pending_end = .{ .key = key, .request = 1 }; 743 events.ui.pending_end = .{ .key = key, .request = 1 };
1107 rt.get(first).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; 744 rt.get(first).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
1108 try events.pollEnd(); 745 try events.ui.pollEnd();
1109 try std.testing.expectEqual(second, rt.workspace.tab().focus.?); 746 try std.testing.expectEqual(second, rt.workspace.tab().focus.?);
1110 try std.testing.expect(events.recovery == null and events.pending_end == null); 747 try std.testing.expect(events.ui.recovery == null and events.ui.pending_end == null);
1111 748
1112 // Returning to the target permits its explicit confirmation. Even if focus 749 // Returning to the target permits its explicit confirmation. Even if focus
1113 // later moves, the rendered context follows the menu's captured attachment. 750 // later moves, the rendered context follows the menu's captured attachment.
1114 _ = rt.workspace.focus(first); 751 _ = rt.workspace.focus(first);
1115 events.pending_end = .{ .key = key, .request = 1 }; 752 events.ui.pending_end = .{ .key = key, .request = 1 };
1116 try events.pollEnd(); 753 try events.ui.pollEnd();
1117 try std.testing.expectEqual(key, events.recovery.?.key); 754 try std.testing.expectEqual(key, events.ui.recovery.?.key);
1118 try std.testing.expectEqual(@as(usize, 0), events.recovery.?.selected); 755 try std.testing.expectEqual(@as(usize, 0), events.ui.recovery.?.selected);
1119 _ = rt.workspace.focus(second); 756 _ = rt.workspace.focus(second);
1120 var popup: PopupFrame = .{}; 757 var popup: PopupFrame = .{};
1121 popup.setRecovery(events.recovery.?, &events); 758 popup.setRecovery(events.ui.recovery.?, &events);
1122 const context = &popup.lines[popup.len - 3]; 759 const context = &popup.lines[popup.len - 3];
1123 try std.testing.expectEqualStrings("end-target on cat", context.bytes[0..context.len]); 760 try std.testing.expectEqualStrings("end-target on cat", context.bytes[0..context.len]);
1124 try std.testing.expectEqualStrings("Cancel", popup.lines[1].bytes[0..popup.lines[1].len]); 761 try std.testing.expectEqualStrings("Cancel", popup.lines[1].bytes[0..popup.lines[1].len]);
@@ -1132,16 +769,16 @@ test "delayed End refusal does not open force menu during command mode" {
1132 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 }; 769 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
1133 const id = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics); 770 const id = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics);
1134 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 771 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1135 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, .command_mode = true }; 772 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } };
1136 defer events.deinit(); 773 defer events.deinit();
1137 try events.relayout(); 774 try events.ui.relayout();
1138 const key = rt.get(id).?.key; 775 const key = rt.get(id).?.key;
1139 events.pending_end = .{ .key = key, .request = 1 }; 776 events.ui.pending_end = .{ .key = key, .request = 1 };
1140 rt.get(id).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; 777 rt.get(id).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
1141 try events.pollEnd(); 778 try events.ui.pollEnd();
1142 try std.testing.expect(events.recovery == null); 779 try std.testing.expect(events.ui.recovery == null);
1143 try std.testing.expect(events.pending_end == null); 780 try std.testing.expect(events.ui.pending_end == null);
1144 try std.testing.expect(events.command_mode); 781 try std.testing.expect(events.ui.command_mode);
1145 } 782 }
1146 783
1147 test "held detach removes one pane and recovery Enter stays consumed after closing" { 784 test "held detach removes one pane and recovery Enter stays consumed after closing" {
@@ -1152,34 +789,34 @@ test "held detach removes one pane and recovery Enter stays consumed after closi
1152 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics); 789 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
1153 _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics); 790 _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
1154 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 791 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1155 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, .command_mode = true }; 792 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .command_mode = true, .wake_ctx = &wake, .wake = Wake.discovery } };
1156 defer events.deinit(); 793 defer events.deinit();
1157 try events.relayout(); 794 try events.ui.relayout();
1158 var ev = std.mem.zeroes(c.SDL_Event); 795 var ev = std.mem.zeroes(c.SDL_Event);
1159 ev.key.type = c.SDL_EVENT_KEY_DOWN; 796 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1160 ev.key.key = c.SDLK_D; 797 ev.key.key = c.SDLK_D;
1161 _ = try events.handle(ev); 798 _ = try events.handle(ev);
1162 ev.key.repeat = true; 799 ev.key.repeat = true;
1163 _ = try events.handle(ev); 800 _ = try events.handle(ev);
1164 try std.testing.expectEqual(@as(usize, 1), events.layout.len); 801 try std.testing.expectEqual(@as(usize, 1), events.ui.layout.len);
1165 try std.testing.expect(rt.get(first) != null); 802 try std.testing.expect(rt.get(first) != null);
1166 ev.key.type = c.SDL_EVENT_KEY_UP; 803 ev.key.type = c.SDL_EVENT_KEY_UP;
1167 _ = try events.handle(ev); 804 _ = try events.handle(ev);
1168 events.recovery = .{ .kind = .force_end, .key = rt.get(first).?.key }; 805 events.ui.recovery = .{ .kind = .force_end, .key = rt.get(first).?.key };
1169 ev.key.type = c.SDL_EVENT_KEY_DOWN; 806 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1170 ev.key.key = c.SDLK_RETURN; 807 ev.key.key = c.SDLK_RETURN;
1171 ev.key.repeat = false; 808 ev.key.repeat = false;
1172 _ = try events.handle(ev); // Cancel is the default action. 809 _ = try events.handle(ev); // Cancel is the default action.
1173 ev.key.repeat = true; 810 ev.key.repeat = true;
1174 _ = try events.handle(ev); 811 _ = try events.handle(ev);
1175 try std.testing.expect(events.recovery == null and events.pending_end == null and events.suppress_text); 812 try std.testing.expect(events.ui.recovery == null and events.ui.pending_end == null and events.ui.suppress_text);
1176 try std.testing.expectEqual(client.session_pump.EndPhase.idle, rt.get(first).?.pump.state().ending.phase); 813 try std.testing.expectEqual(client.session_pump.EndPhase.idle, rt.get(first).?.pump.state().ending.phase);
1177 // A narrow connecting pane must show save failure before its long label. 814 // A narrow connecting pane must show save failure before its long label.
1178 rt.workspace.pane(first).?.identity.label = "very-long-host-and-session-label-that-would-otherwise-hide-the-save-error"; 815 rt.workspace.pane(first).?.identity.label = "very-long-host-and-session-label-that-would-otherwise-hide-the-save-error";
1179 const warning = "Workspace not saved: AccessDenied"; 816 const warning = "Workspace not saved: AccessDenied";
1180 @memcpy(events.save_notice[0..warning.len], warning); 817 @memcpy(events.ui.save_notice[0..warning.len], warning);
1181 events.save_notice_len = warning.len; 818 events.ui.save_notice_len = warning.len;
1182 var placement = events.layout.get(first).?; 819 var placement = events.ui.layout.get(first).?;
1183 placement.header.w = 32 * metrics.cell_w; 820 placement.header.w = 32 * metrics.cell_w;
1184 var header: Header = .{}; 821 var header: Header = .{};
1185 header.set(&events, placement, rt.get(first).?); 822 header.set(&events, placement, rt.get(first).?);
@@ -1191,10 +828,10 @@ const Header = struct {
1191 cells: [512]term.grid.Cell = undefined, 828 cells: [512]term.grid.Cell = undefined,
1192 len: usize = 0, 829 len: usize = 0,
1193 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { 830 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
1194 const focused = events.rt.workspace.tab().focus == p.id; 831 const focused = events.ui.rt.workspace.tab().focus == p.id;
1195 const pending = events.rt.workspace.tab().pending; 832 const pending = events.ui.rt.workspace.tab().pending;
1196 const hint = if (events.save_notice_len != 0) events.save_notice[0..events.save_notice_len] else 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; 833 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.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.ui.notice;
1197 const label = events.rt.workspace.pane(p.id).?.identity.label; 834 const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
1198 var status_buf: [48]u8 = undefined; 835 var status_buf: [48]u8 = undefined;
1199 const status: []const u8 = switch (live.status.phase) { 836 const status: []const u8 = switch (live.status.phase) {
1200 .attached => "", 837 .attached => "",
@@ -1206,11 +843,11 @@ const Header = struct {
1206 .taken => "[taken by another client] ", 843 .taken => "[taken by another client] ",
1207 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable, 844 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable,
1208 }; 845 };
1209 const text = if (events.save_notice_len != 0) 846 const text = if (events.ui.save_notice_len != 0)
1210 std.fmt.bufPrint(&self.bytes, "{s}{s} {s}{s}", .{ if (focused) "> " else " ", hint, status, label }) catch self.bytes[0..] 847 std.fmt.bufPrint(&self.bytes, "{s}{s} {s}{s}", .{ if (focused) "> " else " ", hint, status, label }) catch self.bytes[0..]
1211 else 848 else
1212 std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..]; 849 std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..];
1213 self.setText(text, p.header.w / events.metrics.cell_w); 850 self.setText(text, p.header.w / events.ui.metrics.cell_w);
1214 } 851 }
1215 fn setText(self: *Header, text: []const u8, cols: usize) void { 852 fn setText(self: *Header, text: []const u8, cols: usize) void {
1216 const n = @min(text.len, self.bytes.len); 853 const n = @min(text.len, self.bytes.len);
@@ -1265,9 +902,9 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1265 painted_text: []const u8, 902 painted_text: []const u8,
1266 }; 903 };
1267 var panes: [model.max_panes]PaneState = undefined; 904 var panes: [model.max_panes]PaneState = undefined;
1268 for (events.layout.items(), 0..) |p, i| { 905 for (events.ui.layout.items(), 0..) |p, i| {
1269 const live = events.rt.get(p.id).?; 906 const live = events.ui.rt.get(p.id).?;
1270 panes[i] = .{ .id = p.id, .generation = live.key.generation, .label = events.rt.workspace.pane(p.id).?.identity.label, .outer = p.outer, .header = p.header, .content = p.content, .visible = p.visible, .cols = p.cols, .rows = p.rows, .phase = @tagName(live.status.phase), .exit_code = live.status.exit_code, .painted_seq = live.painted_seq, .painted_text = if (live.painted_seq == 0) "" else try live.snapshot.dumpPlain(a) }; 907 panes[i] = .{ .id = p.id, .generation = live.key.generation, .label = events.ui.rt.workspace.pane(p.id).?.identity.label, .outer = p.outer, .header = p.header, .content = p.content, .visible = p.visible, .cols = p.cols, .rows = p.rows, .phase = @tagName(live.status.phase), .exit_code = live.status.exit_code, .painted_seq = live.painted_seq, .painted_text = if (live.painted_seq == 0) "" else try live.snapshot.dumpPlain(a) };
1271 } 908 }
1272 var w: c_int = 0; 909 var w: c_int = 0;
1273 var h: c_int = 0; 910 var h: c_int = 0;
@@ -1275,7 +912,7 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1275 const PopupRow = struct { label: []const u8, rect: model.Rect }; 912 const PopupRow = struct { label: []const u8, rect: model.Rect };
1276 const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize }; 913 const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize };
1277 var picker_state: ?PopupState = null; 914 var picker_state: ?PopupState = null;
1278 if (events.picker) |picker| { 915 if (events.ui.picker) |picker| {
1279 const view = picker.view(); 916 const view = picker.view();
1280 const rows = try a.alloc(PopupRow, picker.rowCount()); 917 const rows = try a.alloc(PopupRow, picker.rowCount());
1281 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; 918 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) };
@@ -1283,13 +920,13 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1283 } 920 }
1284 const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect }; 921 const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect };
1285 var recovery_state: ?RecoveryState = null; 922 var recovery_state: ?RecoveryState = null;
1286 if (events.recovery) |menu| { 923 if (events.ui.recovery) |menu| {
1287 const view = menu.view(@intCast(@max(events.fb_w, 0)), @intCast(@max(events.fb_h, 0)), events.metrics); 924 const view = menu.view(@intCast(@max(events.ui.fb_w, 0)), @intCast(@max(events.ui.fb_h, 0)), events.ui.metrics);
1288 const rows = try a.alloc(PopupRow, menu.count()); 925 const rows = try a.alloc(PopupRow, menu.count());
1289 for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) }; 926 for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) };
1290 recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect }; 927 recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect };
1291 } 928 }
1292 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 = if (events.save_notice_len != 0) events.save_notice[0..events.save_notice_len] else events.notice, .persistent = events.store != null, .save_enabled = if (events.store) |store| store.writable else false, .pending_end = events.pending_end, .recovery = recovery_state, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{}); 929 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .panes = panes[0..events.ui.layout.len] }, .{});
1293 try writeArtifact(a, path, &.{bytes}); 930 try writeArtifact(a, path, &.{bytes});
1294 } 931 }
1295 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { 932 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void {
@@ -1327,32 +964,32 @@ test "resize mode repeats move one cell without leaking held directions after ex
1327 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); 964 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
1328 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); 965 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
1329 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 966 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1330 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 }; 967 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } };
1331 defer events.deinit(); 968 defer events.deinit();
1332 try events.relayout(); 969 try events.ui.relayout();
1333 try events.command(c.SDLK_R); 970 try events.ui.command(.r);
1334 var ev = std.mem.zeroes(c.SDL_Event); 971 var ev = std.mem.zeroes(c.SDL_Event);
1335 ev.key.type = c.SDL_EVENT_KEY_DOWN; 972 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1336 ev.key.key = c.SDLK_H; 973 ev.key.key = c.SDLK_H;
1337 _ = try events.handle(ev); 974 _ = try events.handle(ev);
1338 ev.key.repeat = true; 975 ev.key.repeat = true;
1339 _ = try events.handle(ev); 976 _ = try events.handle(ev);
1340 const divider = events.layout.boundaries()[0]; 977 const divider = events.ui.layout.boundaries()[0];
1341 try std.testing.expectEqual(@as(u32, 380), divider.position()); 978 try std.testing.expectEqual(@as(u32, 380), divider.position());
1342 ev.key.repeat = false; 979 ev.key.repeat = false;
1343 ev.key.key = c.SDLK_RETURN; 980 ev.key.key = c.SDLK_RETURN;
1344 _ = try events.handle(ev); 981 _ = try events.handle(ev);
1345 try std.testing.expect(!events.resize_mode); 982 try std.testing.expect(!events.ui.resize_mode);
1346 ev.key.key = c.SDLK_H; 983 ev.key.key = c.SDLK_H;
1347 ev.key.repeat = true; 984 ev.key.repeat = true;
1348 _ = try events.handle(ev); 985 _ = try events.handle(ev);
1349 try std.testing.expect(events.suppress_text); 986 try std.testing.expect(events.ui.suppress_text);
1350 try std.testing.expectEqual(divider.rect, events.layout.boundaries()[0].rect); 987 try std.testing.expectEqual(divider.rect, events.ui.layout.boundaries()[0].rect);
1351 ev.key.type = c.SDL_EVENT_KEY_UP; 988 ev.key.type = c.SDL_EVENT_KEY_UP;
1352 _ = try events.handle(ev); 989 _ = try events.handle(ev);
1353 try std.testing.expect(!events.modal_held.contains(c.SDLK_H)); 990 try std.testing.expect(!events.ui.modal_held.contains(c.SDLK_H));
1354 // Physical aliases and ordinary modal text each retain their own release. 991 // Physical aliases and ordinary modal text each retain their own release.
1355 try events.command(c.SDLK_R); 992 try events.ui.command(.r);
1356 ev.key.type = c.SDL_EVENT_KEY_DOWN; 993 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1357 ev.key.repeat = false; 994 ev.key.repeat = false;
1358 ev.key.key = c.SDLK_H; 995 ev.key.key = c.SDLK_H;
@@ -1370,24 +1007,24 @@ test "resize mode repeats move one cell without leaking held directions after ex
1370 ev.key.key = held; 1007 ev.key.key = held;
1371 ev.key.repeat = true; 1008 ev.key.repeat = true;
1372 _ = try events.handle(ev); 1009 _ = try events.handle(ev);
1373 try std.testing.expect(events.suppress_text); 1010 try std.testing.expect(events.ui.suppress_text);
1374 ev.key.type = c.SDL_EVENT_KEY_UP; 1011 ev.key.type = c.SDL_EVENT_KEY_UP;
1375 _ = try events.handle(ev); 1012 _ = try events.handle(ev);
1376 ev.key.type = c.SDL_EVENT_KEY_DOWN; 1013 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1377 } 1014 }
1378 const resized = events.layout.boundaries()[0]; 1015 const resized = events.ui.layout.boundaries()[0];
1379 try events.command(c.SDLK_R); 1016 try events.ui.command(.r);
1380 events.fb_w = 8; 1017 events.ui.fb_w = 8;
1381 events.fb_h = 8; 1018 events.ui.fb_h = 8;
1382 try events.relayout(); 1019 try events.ui.relayout();
1383 ev.key.type = c.SDL_EVENT_KEY_DOWN; 1020 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1384 ev.key.key = c.SDLK_RIGHT; 1021 ev.key.key = c.SDLK_RIGHT;
1385 _ = try events.handle(ev); 1022 _ = try events.handle(ev);
1386 try std.testing.expectEqualStrings("Window too small to resize", events.notice); 1023 try std.testing.expectEqualStrings("Window too small to resize", events.ui.notice);
1387 events.fb_w = 801; 1024 events.ui.fb_w = 801;
1388 events.fb_h = 601; 1025 events.ui.fb_h = 601;
1389 try events.relayout(); 1026 try events.ui.relayout();
1390 try std.testing.expectEqual(resized.rect, events.layout.boundaries()[0].rect); 1027 try std.testing.expectEqual(resized.rect, events.ui.layout.boundaries()[0].rect);
1391 } 1028 }
1392 1029
1393 test "divider drag preserves grab offset clamps signed outside motion and cancels on focus loss" { 1030 test "divider drag preserves grab offset clamps signed outside motion and cancels on focus loss" {
@@ -1398,40 +1035,40 @@ test "divider drag preserves grab offset clamps signed outside motion and cancel
1398 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics); 1035 _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
1399 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics); 1036 _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
1400 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1037 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1401 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 }; 1038 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 801, .fb_h = 601, .wake_ctx = &wake, .wake = Wake.discovery } };
1402 defer events.deinit(); 1039 defer events.deinit();
1403 try events.relayout(); 1040 try events.ui.relayout();
1404 try events.pointerDown(402, 100, 6, 6); 1041 try events.ui.pointerDown(402, 100, 6, 6);
1405 try std.testing.expect(events.drag != null); 1042 try std.testing.expect(events.ui.drag != null);
1406 try events.pointerMove(402, 100); 1043 try events.ui.pointerMove(402, 100);
1407 try std.testing.expectEqual(@as(u32, 400), events.layout.boundaries()[0].position()); 1044 try std.testing.expectEqual(@as(u32, 400), events.ui.layout.boundaries()[0].position());
1408 try events.pointerMove(452, 100); 1045 try events.ui.pointerMove(452, 100);
1409 try std.testing.expectEqual(@as(u32, 450), events.layout.boundaries()[0].position()); 1046 try std.testing.expectEqual(@as(u32, 450), events.ui.layout.boundaries()[0].position());
1410 try events.pointerMove(-100, 100); 1047 try events.ui.pointerMove(-100, 100);
1411 try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); 1048 try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
1412 var ev = std.mem.zeroes(c.SDL_Event); 1049 var ev = std.mem.zeroes(c.SDL_Event);
1413 ev.type = c.SDL_EVENT_WINDOW_FOCUS_LOST; 1050 ev.type = c.SDL_EVENT_WINDOW_FOCUS_LOST;
1414 _ = try events.handle(ev); 1051 _ = try events.handle(ev);
1415 try std.testing.expect(events.drag == null); 1052 try std.testing.expect(events.ui.drag == null);
1416 try events.pointerMove(600, 100); 1053 try events.ui.pointerMove(600, 100);
1417 try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); 1054 try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
1418 try events.pointerDown(20, 100, 6, 6); 1055 try events.ui.pointerDown(20, 100, 6, 6);
1419 try events.command(c.SDLK_R); 1056 try events.ui.command(.r);
1420 try std.testing.expect(events.drag == null); 1057 try std.testing.expect(events.ui.drag == null);
1421 try events.pointerMove(600, 100); 1058 try events.ui.pointerMove(600, 100);
1422 try std.testing.expectEqual(@as(u32, 20), events.layout.boundaries()[0].position()); 1059 try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
1423 try events.pointerDown(20, 100, 6, 6); 1060 try events.ui.pointerDown(20, 100, 6, 6);
1424 ev.key.type = c.SDL_EVENT_KEY_DOWN; 1061 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1425 ev.key.key = c.SDLK_RIGHT; 1062 ev.key.key = c.SDLK_RIGHT;
1426 _ = try events.handle(ev); 1063 _ = try events.handle(ev);
1427 try std.testing.expect(events.drag == null); 1064 try std.testing.expect(events.ui.drag == null);
1428 try std.testing.expectEqual(@as(u32, 30), events.layout.boundaries()[0].position()); 1065 try std.testing.expectEqual(@as(u32, 30), events.ui.layout.boundaries()[0].position());
1429 try events.pointerMove(20, 100); 1066 try events.ui.pointerMove(20, 100);
1430 try std.testing.expectEqual(@as(u32, 30), events.layout.boundaries()[0].position()); 1067 try std.testing.expectEqual(@as(u32, 30), events.ui.layout.boundaries()[0].position());
1431 try events.pointerDown(30, 100, 6, 6); 1068 try events.ui.pointerDown(30, 100, 6, 6);
1432 ev.key.key = c.SDLK_ESCAPE; 1069 ev.key.key = c.SDLK_ESCAPE;
1433 _ = try events.handle(ev); 1070 _ = try events.handle(ev);
1434 try std.testing.expect(events.drag == null and !events.resize_mode); 1071 try std.testing.expect(events.ui.drag == null and !events.ui.resize_mode);
1435 } 1072 }
1436 1073
1437 test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" { 1074 test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" {
@@ -1449,27 +1086,27 @@ test "multi-pane scale transitions resize every content claim and preserve stabl
1449 rt.workspace.arm(.beside); 1086 rt.workspace.arm(.beside);
1450 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics); 1087 const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics);
1451 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1088 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1452 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 }; 1089 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = &cache, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } };
1453 defer events.deinit(); 1090 defer events.deinit();
1454 try events.relayout(); 1091 try events.ui.relayout();
1455 const initial = events.layout; 1092 const initial = events.ui.layout;
1456 const boundary = initial.boundaries()[0]; 1093 const boundary = initial.boundaries()[0];
1457 try events.pointerDown(boundary.rect.x, 100, 6, 6); 1094 try events.ui.pointerDown(boundary.rect.x, 100, 6, 6);
1458 try std.testing.expect(events.drag != null); 1095 try std.testing.expect(events.ui.drag != null);
1459 const old_key = rt.get(first).?.key; 1096 const old_key = rt.get(first).?.key;
1460 var event = std.mem.zeroes(c.SDL_Event); 1097 var event = std.mem.zeroes(c.SDL_Event);
1461 event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED; 1098 event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED;
1462 try std.testing.expect(try events.handle(event)); 1099 try std.testing.expect(try events.handle(event));
1463 try std.testing.expect(events.geometry_dirty); 1100 try std.testing.expect(events.geometry_dirty);
1464 try events.updateGeometry(960, 600, 2); 1101 try events.updateGeometry(960, 600, 2);
1465 try std.testing.expect(events.drag == null); 1102 try std.testing.expect(events.ui.drag == null);
1466 for (events.layout.items(), initial.items()) |p, before| { 1103 for (events.ui.layout.items(), initial.items()) |p, before| {
1467 try std.testing.expect(p.cols < before.cols and p.rows < before.rows); 1104 try std.testing.expect(p.cols < before.cols and p.rows < before.rows);
1468 try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols); 1105 try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols);
1469 try std.testing.expectEqual(p.rows, rt.get(p.id).?.size.rows); 1106 try std.testing.expectEqual(p.rows, rt.get(p.id).?.size.rows);
1470 } 1107 }
1471 try events.updateGeometry(1920, 1200, 2); 1108 try events.updateGeometry(1920, 1200, 2);
1472 for (events.layout.items(), initial.items()) |p, before| { 1109 for (events.ui.layout.items(), initial.items()) |p, before| {
1473 try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1)); 1110 try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1));
1474 try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1)); 1111 try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1));
1475 try std.testing.expectEqual(@as(u32, face.cell_h), p.content.y); 1112 try std.testing.expectEqual(@as(u32, face.cell_h), p.content.y);
@@ -1479,7 +1116,7 @@ test "multi-pane scale transitions resize every content claim and preserve stabl
1479 try std.testing.expectEqual(@as(usize, 1), cache.runs.count()); 1116 try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
1480 try std.testing.expectEqual(@as(u16, 32), face.pixels); 1117 try std.testing.expectEqual(@as(u16, 32), face.pixels);
1481 try events.updateGeometry(960, 600, 1); 1118 try events.updateGeometry(960, 600, 1);
1482 try std.testing.expectEqualDeep(initial.items(), events.layout.items()); 1119 try std.testing.expectEqualDeep(initial.items(), events.ui.layout.items());
1483 try std.testing.expect(rt.accepts(old_key)); 1120 try std.testing.expect(rt.accepts(old_key));
1484 // Removal joins before destroying the identity/context. A queued old key 1121 // Removal joins before destroying the identity/context. A queued old key
1485 // cannot select a new slot occupant, even after a further insertion. 1122 // cannot select a new slot occupant, even after a further insertion.
@@ -1497,7 +1134,7 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1497 defer rt.deinit(); 1134 defer rt.deinit();
1498 // No live attachment is needed to test the actual SDL event routing state. 1135 // No live attachment is needed to test the actual SDL event routing state.
1499 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1136 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1500 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 }; 1137 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 960, .fb_h = 600, .wake_ctx = &wake, .wake = Wake.discovery } };
1501 defer events.deinit(); 1138 defer events.deinit();
1502 var event = std.mem.zeroes(c.SDL_Event); 1139 var event = std.mem.zeroes(c.SDL_Event);
1503 event.key.type = c.SDL_EVENT_KEY_DOWN; 1140 event.key.type = c.SDL_EVENT_KEY_DOWN;
@@ -1505,12 +1142,12 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1505 for ([_]u16{ 0, c.SDL_KMOD_SHIFT, c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT, c.SDL_KMOD_CTRL | c.SDL_KMOD_GUI, c.SDL_KMOD_CTRL | c.SDL_KMOD_MODE }) |mods| { 1142 for ([_]u16{ 0, c.SDL_KMOD_SHIFT, c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT, c.SDL_KMOD_CTRL | c.SDL_KMOD_GUI, c.SDL_KMOD_CTRL | c.SDL_KMOD_MODE }) |mods| {
1506 event.key.mod = mods; 1143 event.key.mod = mods;
1507 try std.testing.expect(try events.handle(event)); 1144 try std.testing.expect(try events.handle(event));
1508 try std.testing.expect(!events.command_mode); 1145 try std.testing.expect(!events.ui.command_mode);
1509 } 1146 }
1510 // Bare Ctrl activates the prefix; Shift is also accepted below. 1147 // Bare Ctrl activates the prefix; Shift is also accepted below.
1511 event.key.mod = c.SDL_KMOD_CTRL; 1148 event.key.mod = c.SDL_KMOD_CTRL;
1512 try std.testing.expect(try events.handle(event)); 1149 try std.testing.expect(try events.handle(event));
1513 try std.testing.expect(events.command_mode and events.suppress_text); 1150 try std.testing.expect(events.ui.command_mode and events.ui.suppress_text);
1514 event.text.type = c.SDL_EVENT_TEXT_INPUT; 1151 event.text.type = c.SDL_EVENT_TEXT_INPUT;
1515 event.text.text = "\\"; 1152 event.text.text = "\\";
1516 try std.testing.expect(try events.handle(event)); 1153 try std.testing.expect(try events.handle(event));
@@ -1518,33 +1155,33 @@ test "command key text is consumed and subsequent ordinary text is not suppresse
1518 event.key.type = c.SDL_EVENT_KEY_DOWN; 1155 event.key.type = c.SDL_EVENT_KEY_DOWN;
1519 event.key.key = c.SDLK_H; 1156 event.key.key = c.SDLK_H;
1520 try std.testing.expect(try events.handle(event)); 1157 try std.testing.expect(try events.handle(event));
1521 try std.testing.expect(!events.command_mode and events.suppress_text); 1158 try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text);
1522 event.text.type = c.SDL_EVENT_TEXT_INPUT; 1159 event.text.type = c.SDL_EVENT_TEXT_INPUT;
1523 event.text.text = "h"; 1160 event.text.text = "h";
1524 try std.testing.expect(try events.handle(event)); 1161 try std.testing.expect(try events.handle(event));
1525 try std.testing.expect(!events.suppress_text); 1162 try std.testing.expect(!events.ui.suppress_text);
1526 event = std.mem.zeroes(c.SDL_Event); 1163 event = std.mem.zeroes(c.SDL_Event);
1527 event.key.type = c.SDL_EVENT_KEY_DOWN; 1164 event.key.type = c.SDL_EVENT_KEY_DOWN;
1528 event.key.key = c.SDLK_H; 1165 event.key.key = c.SDLK_H;
1529 event.key.repeat = true; 1166 event.key.repeat = true;
1530 try std.testing.expect(try events.handle(event)); 1167 try std.testing.expect(try events.handle(event));
1531 try std.testing.expect(events.suppress_text); 1168 try std.testing.expect(events.ui.suppress_text);
1532 event.key.type = c.SDL_EVENT_KEY_UP; 1169 event.key.type = c.SDL_EVENT_KEY_UP;
1533 try std.testing.expect(try events.handle(event)); 1170 try std.testing.expect(try events.handle(event));
1534 try std.testing.expect(events.consumed_key == null); 1171 try std.testing.expect(events.ui.consumed_key == null);
1535 event.key.type = c.SDL_EVENT_KEY_DOWN; 1172 event.key.type = c.SDL_EVENT_KEY_DOWN;
1536 event.key.repeat = false; 1173 event.key.repeat = false;
1537 event.key.key = c.SDLK_X; 1174 event.key.key = c.SDLK_X;
1538 try std.testing.expect(try events.handle(event)); 1175 try std.testing.expect(try events.handle(event));
1539 try std.testing.expect(!events.suppress_text); 1176 try std.testing.expect(!events.ui.suppress_text);
1540 // Repeated prefix exits mode; its shared keymap encoding is 0x1c. 1177 // Repeated prefix exits mode; its shared keymap encoding is 0x1c.
1541 // The integration fixture verifies the emitted byte count with a real PTY. 1178 // The integration fixture verifies the emitted byte count with a real PTY.
1542 event.key.key = c.SDLK_BACKSLASH; 1179 event.key.key = c.SDLK_BACKSLASH;
1543 event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; 1180 event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
1544 try std.testing.expect(try events.handle(event)); 1181 try std.testing.expect(try events.handle(event));
1545 try std.testing.expect(events.command_mode); 1182 try std.testing.expect(events.ui.command_mode);
1546 try std.testing.expect(try events.handle(event)); 1183 try std.testing.expect(try events.handle(event));
1547 try std.testing.expect(!events.command_mode); 1184 try std.testing.expect(!events.ui.command_mode);
1548 var bytes: [keymap.max_seq_len]u8 = undefined; 1185 var bytes: [keymap.max_seq_len]u8 = undefined;
1549 try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); 1186 try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes));
1550 } 1187 }
@@ -1598,13 +1235,13 @@ const PopupFrame = struct {
1598 return cols; 1235 return cols;
1599 } 1236 }
1600 fn setRecovery(self: *PopupFrame, menu: Recovery, events: *Events) void { 1237 fn setRecovery(self: *PopupFrame, menu: Recovery, events: *Events) void {
1601 const view = menu.view(@intCast(@max(events.fb_w, 0)), @intCast(@max(events.fb_h, 0)), events.metrics); 1238 const view = menu.view(@intCast(@max(events.ui.fb_w, 0)), @intCast(@max(events.ui.fb_h, 0)), events.ui.metrics);
1602 const cols = self.reset(view.rect, menu.count() + 4, events.metrics.cell_w); 1239 const cols = self.reset(view.rect, menu.count() + 4, events.ui.metrics.cell_w);
1603 self.selected_line = menu.selected + 1; 1240 self.selected_line = menu.selected + 1;
1604 self.lines[0].setText(menu.title(), cols); 1241 self.lines[0].setText(menu.title(), cols);
1605 for (0..menu.count()) |i| self.lines[i + 1].setText(menu.label(i), cols); 1242 for (0..menu.count()) |i| self.lines[i + 1].setText(menu.label(i), cols);
1606 if (events.rt.accepts(menu.key)) { 1243 if (events.ui.rt.accepts(menu.key)) {
1607 const identity = &events.rt.workspace.pane(menu.key.pane).?.identity; 1244 const identity = &events.ui.rt.workspace.pane(menu.key.pane).?.identity;
1608 const host = switch (identity.target) { 1245 const host = switch (identity.target) {
1609 .sock, .via => |text| text, 1246 .sock, .via => |text| text,
1610 .quic => |target| target.host_port, 1247 .quic => |target| target.host_port,
@@ -1618,15 +1255,15 @@ const PopupFrame = struct {
1618 self.lines[self.len - 1].setText("Up/Down or j/k choose | Enter selects | Esc closes", cols); 1255 self.lines[self.len - 1].setText("Up/Down or j/k choose | Enter selects | Esc closes", cols);
1619 } 1256 }
1620 fn setEmpty(self: *PopupFrame, events: *Events) void { 1257 fn setEmpty(self: *PopupFrame, events: *Events) void {
1621 const width: u32 = @intCast(@max(events.fb_w, 0)); 1258 const width: u32 = @intCast(@max(events.ui.fb_w, 0));
1622 const height: u32 = @intCast(@max(events.fb_h, 0)); 1259 const height: u32 = @intCast(@max(events.ui.fb_h, 0));
1623 const w = @min(width, @as(u32, events.metrics.cell_w) * 74); 1260 const w = @min(width, @as(u32, events.ui.metrics.cell_w) * 74);
1624 const h = @min(height, @as(u32, events.metrics.cell_h) * 4); 1261 const h = @min(height, @as(u32, events.ui.metrics.cell_h) * 4);
1625 const cols = self.reset(.{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, 4, events.metrics.cell_w); 1262 const cols = self.reset(.{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, 4, events.ui.metrics.cell_w);
1626 self.selected_line = 1; 1263 self.selected_line = 1;
1627 self.lines[0].setText("No panes", cols); 1264 self.lines[0].setText("No panes", cols);
1628 self.lines[1].setText("Add pane", cols); 1265 self.lines[1].setText("Add pane", cols);
1629 self.lines[2].setText(events.notice, cols); 1266 self.lines[2].setText(events.ui.notice, cols);
1630 self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols); 1267 self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols);
1631 } 1268 }
1632 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void { 1269 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void {
@@ -1677,7 +1314,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1677 const picker = try a.create(picker_mod.Picker); 1314 const picker = try a.create(picker_mod.Picker);
1678 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 }; 1315 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 };
1679 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1316 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1680 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 }; 1317 var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker, .wake_ctx = &wake, .wake = Wake.discovery } };
1681 defer events.deinit(); 1318 defer events.deinit();
1682 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); 1319 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
1683 picker.level = .session_name; 1320 picker.level = .session_name;
@@ -1697,7 +1334,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1697 event.key.mod = c.SDL_KMOD_CTRL; 1334 event.key.mod = c.SDL_KMOD_CTRL;
1698 try std.testing.expect(try events.handle(event)); 1335 try std.testing.expect(try events.handle(event));
1699 try std.testing.expect(try events.handle(event)); 1336 try std.testing.expect(try events.handle(event));
1700 try std.testing.expect(!events.command_mode and events.suppress_text); 1337 try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text);
1701 picker.level = .sessions; 1338 picker.level = .sessions;
1702 picker.session_count = 1; 1339 picker.session_count = 1;
1703 picker.sessions[0][0] = 'x'; 1340 picker.sessions[0][0] = 'x';
@@ -1706,17 +1343,17 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1706 event.key.type = c.SDL_EVENT_KEY_DOWN; 1343 event.key.type = c.SDL_EVENT_KEY_DOWN;
1707 event.key.key = c.SDLK_RETURN; 1344 event.key.key = c.SDLK_RETURN;
1708 try std.testing.expect(try events.handle(event)); 1345 try std.testing.expect(try events.handle(event));
1709 try std.testing.expect(events.picker == null); 1346 try std.testing.expect(events.ui.picker == null);
1710 try std.testing.expectEqual(@as(usize, 2), events.layout.len); 1347 try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len);
1711 event.key.repeat = true; 1348 event.key.repeat = true;
1712 try std.testing.expect(try events.handle(event)); 1349 try std.testing.expect(try events.handle(event));
1713 try std.testing.expect(events.suppress_text and events.consumed_key == c.SDLK_RETURN); 1350 try std.testing.expect(events.ui.suppress_text and events.ui.consumed_key == c.SDLK_RETURN);
1714 event.key.type = c.SDL_EVENT_KEY_UP; 1351 event.key.type = c.SDL_EVENT_KEY_UP;
1715 try std.testing.expect(try events.handle(event)); 1352 try std.testing.expect(try events.handle(event));
1716 try std.testing.expect(events.consumed_key == null); 1353 try std.testing.expect(events.ui.consumed_key == null);
1717 event = std.mem.zeroes(c.SDL_Event); 1354 event = std.mem.zeroes(c.SDL_Event);
1718 event.key.type = c.SDL_EVENT_KEY_DOWN; 1355 event.key.type = c.SDL_EVENT_KEY_DOWN;
1719 event.key.key = c.SDLK_X; 1356 event.key.key = c.SDLK_X;
1720 try std.testing.expect(try events.handle(event)); 1357 try std.testing.expect(try events.handle(event));
1721 try std.testing.expect(!events.suppress_text); 1358 try std.testing.expect(!events.ui.suppress_text);
1722 } 1359 }
src/gui/interaction.zig
Old New
@@ -0,0 +1,500 @@
1 //! Native interaction policy, independent of window APIs and rendering.
2 //! The frame adapter supplies logical keys, physical pointer coordinates and
3 //! measured geometry. This controller owns modes, menu lifetimes, input release
4 //! ownership and asynchronous actions bound to attachment identities.
5 const std = @import("std");
6 const client = @import("client");
7 const keymap = client.keymap;
8 const model = @import("workspace.zig");
9 const runtime = @import("runtime.zig");
10 const picker_mod = @import("picker.zig");
11 const persistence = @import("persistence.zig");
12
13 pub const Key = enum { v, b, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other };
14 /// code preserves platform key identity so aliases release independently.
15 /// kind describes GUI meaning; terminal carries the platform's keymap translation.
16 pub const KeyDown = struct {
17 code: u32,
18 kind: Key = .other,
19 prefix: bool = false,
20 modified: bool = false,
21 repeat: bool = false,
22 terminal: ?keymap.Event = null,
23 };
24 pub const PendingEnd = struct { key: model.Attachment, request: u64 };
25
26 pub const Recovery = struct {
27 kind: enum { recovery, ending, force_end },
28 key: model.Attachment,
29 selected: usize = 0,
30 notice: [1024]u8 = @splat(0),
31 notice_len: usize = 0,
32 pub fn count(self: Recovery) usize {
33 return switch (self.kind) {
34 .recovery => 3,
35 .ending => 1,
36 .force_end => 2,
37 };
38 }
39 pub fn label(self: Recovery, index: usize) []const u8 {
40 return switch (self.kind) {
41 .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index],
42 .ending => "Dismiss (End is already requested)",
43 .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index],
44 };
45 }
46 pub fn title(self: Recovery) []const u8 {
47 return switch (self.kind) {
48 .recovery => "Pane actions",
49 .ending => "Ending session...",
50 .force_end => "Other clients are attached",
51 };
52 }
53 pub fn setNotice(self: *Recovery, text: []const u8) void {
54 self.notice_len = @min(text.len, self.notice.len);
55 @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]);
56 }
57 pub fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) picker_mod.View {
58 const w = @min(width, @as(u32, metrics.cell_w) * 74);
59 const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4)));
60 return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h };
61 }
62 };
63 pub const Controller = struct {
64 rt: *runtime.Runtime,
65 picker: ?*picker_mod.Picker = null,
66 next_request: u64 = 1,
67 key_path: ?[]const u8 = null,
68 local_target: ?client.Target = null,
69 store: ?*persistence.Store = null,
70 intent_dirty: bool = false,
71 save_notice: [256]u8 = @splat(0),
72 save_notice_len: usize = 0,
73 recovery: ?Recovery = null,
74 pending_end: ?PendingEnd = null,
75 notice_owned: [1024]u8 = @splat(0),
76 layout: model.Layout = .{},
77 metrics: model.Metrics,
78 fb_w: i32,
79 fb_h: i32,
80 dirty: bool = true,
81 suppress_text: bool = false,
82 command_mode: bool = false,
83 resize_mode: bool = false,
84 modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty,
85 drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null,
86 consumed_key: ?u32 = null,
87 notice: []const u8 = "",
88
89 wake_ctx: ?*anyopaque = null,
90 wake: ?*const fn (?*anyopaque, client.discovery.Ticket) void = null,
91 pub fn deinit(self: *Controller) void {
92 if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{});
93 self.cancelDrag();
94 self.modal_held.deinit(self.rt.alloc);
95 if (self.picker) |picker| picker.deinit();
96 }
97 pub fn command(self: *Controller, key: Key) !void {
98 self.cancelDrag();
99 self.command_mode = false;
100 self.notice = "";
101 const ws = &self.rt.workspace;
102 if (keyDirection(key)) |direction| {
103 const before = ws.tab().focus;
104 ws.moveFocus(&self.layout, direction);
105 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
106 } else switch (key) {
107 .v => ws.arm(.beside),
108 .b => ws.arm(.stacked),
109 .r => self.resize_mode = true,
110 .d => if (ws.tab().focus) |id| try self.detach(id),
111 .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
112 .p => if (ws.tab().focus) |id| {
113 self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key };
114 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
115 },
116 .escape => ws.cancel(),
117 .enter, .keypad_enter => {
118 self.openPicker(.insert) catch |err| {
119 self.notice = @errorName(err);
120 self.dirty = true;
121 return;
122 };
123 },
124 else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add",
125 }
126 self.dirty = true;
127 }
128
129 pub fn finishPicker(self: *Controller) !void {
130 if (self.picker) |picker| if (picker.closed) {
131 const inserted = picker.inserted;
132 picker.deinit();
133 self.picker = null;
134 if (inserted) {
135 try self.relayout();
136 self.intent_dirty = true;
137 }
138 self.dirty = true;
139 };
140 }
141 pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void {
142 self.cancelDrag();
143 self.recovery = null;
144 self.notice = "";
145 const picker = try picker_mod.Picker.initMode(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_ctx, self.wake, mode);
146 errdefer picker.deinit();
147 if (picker.origin == null) if (self.local_target) |target| try picker.includeTarget(target);
148 self.picker = picker;
149 self.dirty = true;
150 }
151 pub fn setNotice(self: *Controller, text: []const u8) void {
152 const n = @min(text.len, self.notice_owned.len);
153 @memcpy(self.notice_owned[0..n], text[0..n]);
154 self.notice = self.notice_owned[0..n];
155 self.dirty = true;
156 }
157 pub fn detach(self: *Controller, id: model.PaneId) !void {
158 self.cancelDrag();
159 self.resize_mode = false;
160 if (self.pending_end) |pending| if (pending.key.pane == id) {
161 self.pending_end = null;
162 self.setNotice("Pane detached; the pending End outcome is unknown");
163 };
164 self.recovery = null;
165 self.rt.remove(id);
166 try self.relayout();
167 self.intent_dirty = true;
168 }
169 pub fn beginEnd(self: *Controller, key: model.Attachment, force: bool) !void {
170 if (!self.rt.accepts(key)) return;
171 if (self.pending_end != null) {
172 self.setNotice("An End request is still pending");
173 return;
174 }
175 const request = self.next_request;
176 self.next_request += 1;
177 self.rt.get(key.pane).?.pump.say(.{ .end = .{ .request = request, .force = force } }) catch |err| {
178 self.setNotice(@errorName(err));
179 self.recovery = null;
180 return;
181 };
182 self.pending_end = .{ .key = key, .request = request };
183 self.recovery = .{ .kind = .ending, .key = key };
184 self.dirty = true;
185 }
186 pub fn pollEnd(self: *Controller) !void {
187 const pending = self.pending_end orelse return;
188 if (!self.rt.accepts(pending.key)) {
189 self.pending_end = null;
190 self.setNotice("Pane changed; the earlier End outcome is unknown");
191 return;
192 }
193 const result = self.rt.get(pending.key.pane).?.status.ending;
194 if (result.request != pending.request or result.phase == .pending) return;
195 self.pending_end = null;
196 const own_menu = if (self.recovery) |menu| menu.kind == .ending and std.meta.eql(menu.key, pending.key) else false;
197 if (own_menu) self.recovery = null;
198 switch (result.phase) {
199 .accepted => try self.detach(pending.key.pane),
200 .refused => {
201 self.setNotice(result.reasonText());
202 if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.picker == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) {
203 self.recovery = .{ .kind = .force_end, .key = pending.key };
204 self.recovery.?.setNotice(result.reasonText());
205 }
206 },
207 .unknown => self.setNotice(result.reasonText()),
208 else => {},
209 }
210 self.dirty = true;
211 }
212 pub fn recoveryAction(self: *Controller) !void {
213 const menu = self.recovery orelse return;
214 self.recovery = null;
215 self.dirty = true;
216 if (!self.rt.accepts(menu.key)) {
217 self.setNotice("The pane changed");
218 return;
219 }
220 switch (menu.kind) {
221 .ending => self.setNotice("End requested; waiting for its outcome"),
222 .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true),
223 .recovery => switch (menu.selected) {
224 0, 1 => {
225 if (self.pending_end != null) {
226 self.setNotice("Wait for the pending End outcome before replacing the attachment");
227 return;
228 }
229 if (menu.selected == 0) {
230 self.rt.retry(menu.key.pane, self.layout.get(menu.key.pane).?) catch |err| {
231 self.setNotice(@errorName(err));
232 return;
233 };
234 self.intent_dirty = true;
235 } else {
236 _ = self.rt.workspace.focus(menu.key.pane);
237 try self.openPicker(.replace);
238 }
239 },
240 2 => try self.detach(menu.key.pane),
241 else => unreachable,
242 },
243 }
244 }
245 pub fn saveIntent(self: *Controller) void {
246 if (!self.intent_dirty or self.drag != null) return;
247 self.intent_dirty = false;
248 const store = self.store orelse return;
249 store.save(&self.rt.workspace) catch |err| {
250 const text = std.fmt.bufPrint(&self.save_notice, "Workspace not saved: {s}", .{@errorName(err)}) catch unreachable;
251 self.save_notice_len = text.len;
252 self.dirty = true;
253 return;
254 };
255 self.save_notice_len = 0;
256 }
257 pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void {
258 self.cancelDrag();
259 if (self.recovery) |menu| {
260 const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
261 for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) {
262 self.recovery.?.selected = i;
263 try self.recoveryAction();
264 break;
265 };
266 } else if (self.picker) |picker| {
267 try picker.click(x, y);
268 try self.finishPicker();
269 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
270 const d = self.layout.divider(id).?;
271 const position = if (d.direction == .beside) x else y;
272 self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() };
273 } else if (!self.resize_mode) {
274 if (self.layout.hit(x, y)) |id| {
275 self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id;
276 _ = self.rt.workspace.focus(id);
277 } else if (self.layout.len == 0) try self.openPicker(.insert);
278 }
279 self.dirty = true;
280 }
281 pub fn pointerMove(self: *Controller, x: i64, y: i64) !void {
282 const drag = self.drag orelse return;
283 if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag();
284 const d = self.layout.divider(drag.id) orelse return self.cancelDrag();
285 const position = (if (d.direction == .beside) x else y) - drag.offset;
286 if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
287 self.drag.?.changed = true;
288 try self.relayout();
289 }
290 }
291 pub fn cancelDrag(self: *Controller) void {
292 if (self.drag == null) return;
293 self.intent_dirty = self.intent_dirty or self.drag.?.changed;
294 self.drag = null;
295 }
296 pub fn sendKey(self: *Controller, key: keymap.Event) !void {
297 var buf: [keymap.max_seq_len]u8 = undefined;
298 const bytes = keymap.encode(key, &buf);
299 if (bytes.len != 0) try self.rt.input(bytes);
300 }
301 pub fn relayout(self: *Controller) !void {
302 self.layout = self.rt.workspace.layout(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
303 try self.rt.resize(&self.layout);
304 self.dirty = true;
305 }
306 pub fn textInput(self: *Controller, text: []const u8) !void {
307 if (!self.suppress_text) {
308 if (self.picker) |picker| {
309 try picker.text(text);
310 self.dirty = true;
311 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) try self.rt.input(text);
312 }
313 self.suppress_text = false;
314 }
315 pub fn keyUp(self: *Controller, code: u32) void {
316 _ = self.modal_held.remove(code);
317 if (self.consumed_key == code) self.consumed_key = null;
318 self.suppress_text = false;
319 }
320 pub fn focusLost(self: *Controller) void {
321 self.suppress_text = false;
322 self.command_mode = false;
323 self.resize_mode = false;
324 self.modal_held.clearRetainingCapacity();
325 self.cancelDrag();
326 self.consumed_key = null;
327 self.dirty = true;
328 }
329 pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void {
330 if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) self.cancelDrag();
331 self.fb_w = w;
332 self.fb_h = h;
333 self.metrics = metrics;
334 if (self.picker) |picker| {
335 picker.width = @intCast(@max(w, 0));
336 picker.height = @intCast(@max(h, 0));
337 picker.metrics = metrics;
338 }
339 try self.relayout();
340 }
341 pub fn keyDown(self: *Controller, input: KeyDown) !void {
342 std.debug.assert(self.picker == null or self.recovery == null);
343 self.suppress_text = false;
344 const key = input.code;
345 const kind = input.kind;
346 if (!self.resize_mode and self.recovery == null and self.picker == null and self.modal_held.contains(key)) {
347 self.suppress_text = true;
348 return;
349 }
350 if (self.consumed_key == key) {
351 self.suppress_text = true;
352 return;
353 }
354 if (self.recovery) |*menu| {
355 try self.modal_held.put(self.rt.alloc, key, {});
356 self.suppress_text = true;
357 if (menuKey(kind, false)) |mapped| switch (mapped) {
358 .up => menu.selected -|= 1,
359 .down => menu.selected = @min(menu.selected + 1, menu.count() - 1),
360 .escape => {
361 self.consumed_key = key;
362 self.recovery = null;
363 },
364 .enter => {
365 self.consumed_key = key;
366 try self.recoveryAction();
367 },
368 .backspace => {},
369 };
370 self.dirty = true;
371 return;
372 }
373 if (self.resize_mode) {
374 self.cancelDrag();
375 try self.modal_held.put(self.rt.alloc, key, {});
376 self.suppress_text = true;
377 if (kind == .escape or kind == .enter or kind == .keypad_enter) {
378 self.resize_mode = false;
379 self.consumed_key = key;
380 self.notice = "";
381 } else if (keyDirection(kind)) |direction| {
382 if (!self.layout.fits) {
383 self.notice = "Window too small to resize";
384 } else {
385 self.notice = "";
386 if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
387 try self.relayout();
388 self.intent_dirty = true;
389 }
390 }
391 }
392 self.dirty = true;
393 return;
394 }
395 if (self.picker) |picker| {
396 try self.modal_held.put(self.rt.alloc, key, {});
397 const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions);
398 if (mapped) |k| {
399 self.suppress_text = true;
400 if (k == .enter or k == .escape) self.consumed_key = key;
401 try picker.key(k);
402 } else if (input.modified) self.suppress_text = true;
403 self.dirty = true;
404 try self.finishPicker();
405 return;
406 }
407 if (input.prefix) {
408 self.cancelDrag();
409 self.suppress_text = true;
410 if (!input.repeat) {
411 if (self.command_mode) {
412 self.command_mode = false;
413 try self.sendKey(input.terminal.?);
414 } else self.command_mode = true;
415 self.notice = "";
416 self.dirty = true;
417 }
418 } else if (self.command_mode) {
419 self.consumed_key = key;
420 self.suppress_text = true;
421 try self.command(kind);
422 } else if (kind == .escape and self.rt.workspace.tab().pending != null) {
423 self.rt.workspace.cancel();
424 self.dirty = true;
425 } else {
426 if (input.terminal) |mapped| {
427 try self.sendKey(mapped);
428 self.suppress_text = mapped.key == .char;
429 }
430 }
431 }
432 };
433 fn keyDirection(key: Key) ?model.Neighbor {
434 return switch (key) {
435 .h, .left => .left,
436 .j, .down => .down,
437 .k, .up => .up,
438 .l, .right => .right,
439 else => null,
440 };
441 }
442
443 /// Pickers and Pane actions share navigation; editors retain ordinary j/k text.
444 fn menuKey(key: Key, editing: bool) ?picker_mod.Key {
445 return switch (key) {
446 .up => .up,
447 .down => .down,
448 .k => if (editing) null else .up,
449 .j => if (editing) null else .down,
450 .enter, .keypad_enter => .enter,
451 .escape => .escape,
452 .backspace => .backspace,
453 else => null,
454 };
455 }
456
457 test "controller preserves exact held keys across modal dismissal and text edges" {
458 var rt = runtime.Runtime.init(std.testing.allocator, .{});
459 defer rt.deinit();
460 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
461 defer ui.deinit();
462 ui.recovery = .{ .kind = .recovery, .key = .{ .pane = 1, .generation = 1 } };
463 // Two platform keys share navigation meaning but have independent releases.
464 try ui.keyDown(.{ .code = 101, .kind = .down });
465 try ui.keyDown(.{ .code = 102, .kind = .down });
466 try std.testing.expectEqual(@as(usize, 2), ui.recovery.?.selected);
467 try ui.keyDown(.{ .code = 27, .kind = .escape });
468 ui.keyUp(27);
469 ui.keyUp(101);
470 try std.testing.expect(ui.recovery == null and ui.modal_held.contains(102));
471 try std.testing.expect(!ui.modal_held.contains(101));
472 // Retained ownership is checked before prefix dispatch, even after dismissal.
473 try ui.keyDown(.{ .code = 102, .prefix = true, .repeat = true });
474 try std.testing.expect(ui.suppress_text and !ui.command_mode);
475 try ui.textInput("ignored modal repeat");
476 try std.testing.expect(!ui.suppress_text);
477 ui.keyUp(102);
478 try ui.keyDown(.{ .code = 102, .prefix = true });
479 try std.testing.expect(ui.command_mode);
480 try ui.keyDown(.{ .code = 114, .kind = .r });
481 try std.testing.expect(ui.resize_mode and !ui.command_mode);
482 ui.focusLost();
483 try std.testing.expect(!ui.resize_mode and !ui.command_mode and !ui.suppress_text);
484 try std.testing.expect(ui.consumed_key == null and ui.modal_held.count() == 0);
485 }
486
487 test "controller modal input takes precedence over the prefix" {
488 var rt = runtime.Runtime.init(std.testing.allocator, .{});
489 defer rt.deinit();
490 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
491 defer ui.deinit();
492 ui.recovery = .{ .kind = .recovery, .key = .{ .pane = 1, .generation = 1 } };
493 try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true });
494 try std.testing.expect(ui.recovery != null and !ui.command_mode and ui.suppress_text);
495 ui.recovery = null;
496 ui.keyUp(92);
497 ui.resize_mode = true;
498 try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true });
499 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text);
500 }
src/gui/native.zig
Old New
@@ -10,6 +10,7 @@ const std = @import("std");
10 const client = @import("client"); 10 const client = @import("client");
11 const term = @import("term"); 11 const term = @import("term");
12 12
13 pub const interaction = @import("interaction.zig");
13 pub const frame = @import("frame.zig"); 14 pub const frame = @import("frame.zig");
14 pub const workspace = @import("workspace.zig"); 15 pub const workspace = @import("workspace.zig");
15 pub const runtime = @import("runtime.zig"); 16 pub const runtime = @import("runtime.zig");
@@ -28,6 +29,7 @@ pub fn run(alloc: std.mem.Allocator, opts: frame.Options) !u8 {
28 test { 29 test {
29 _ = client; 30 _ = client;
30 _ = term; 31 _ = term;
32 _ = interaction;
31 _ = frame; 33 _ = frame;
32 _ = workspace; 34 _ = workspace;
33 _ = runtime; 35 _ = runtime;
test/native_scale.py
Old New
@@ -0,0 +1,84 @@
1 #!/usr/bin/env python3
2 """Scale transitions on an explicitly selected, isolated Sway output.
3
4 Use the compositor's XDG_RUNTIME_DIR, WAYLAND_DISPLAY and SWAYSOCK. This changes
5 the named output's scale and restores it on exit; never point it at a desktop
6 output. Both binaries must come from the same release build.
7 """
8 import argparse
9 import json
10 import os
11 import subprocess
12 import sys
13
14 sys.dont_write_bytecode = True
15 from native_resize import (ResizeRig, build_nested, drag_and_keys, pty_sizes,
16 pixels_and_batch, edge, one_divider)
17 from native_tiling import require
18
19
20 def sway(*args):
21 result = subprocess.run(['swaymsg', '-r', *args], check=True,
22 capture_output=True, text=True, timeout=5)
23 return json.loads(result.stdout)
24
25
26 def main():
27 parser = argparse.ArgumentParser(description=__doc__)
28 parser.add_argument('mux')
29 parser.add_argument('muxg')
30 parser.add_argument('--output', required=True, help='owned headless Sway output')
31 args = parser.parse_args()
32 require(args.output.startswith('HEADLESS-'), 'use an isolated headless output')
33 for name in ('SWAYSOCK', 'XDG_RUNTIME_DIR', 'WAYLAND_DISPLAY'):
34 require(os.environ.get(name), 'set the isolated compositor ' + name)
35 outputs = sway('-t', 'get_outputs')
36 output = next((o for o in outputs if o['name'] == args.output and o['active']), None)
37 require(output is not None, 'selected output is not active')
38 original_scale = output['scale']
39
40 def scale(value):
41 require(all(r['success'] for r in sway('output', args.output, 'scale', str(value))),
42 'compositor refused scale change')
43
44 os.environ['MUXG_VIDEODRIVER'] = 'wayland'
45 rig = ResizeRig(args.mux, args.muxg)
46 try:
47 scale(2)
48 identities, sessions = build_nested(rig)
49 command = (f'[pid={rig.gui.pid}] move container to output {args.output}, '
50 'floating enable, border none, resize set width 1000 px height 650 px')
51 require(all(r['success'] for r in sway(command)), 'could not place owned window')
52 rig.wait_state(lambda s: s['logical_width'] == 1000 and s['logical_height'] == 650
53 and s['width'] == 2 * s['logical_width'])
54 drag_and_keys(rig, identities, sessions)
55 pixels_and_batch(rig, identities, sessions)
56 for value in (1, 1.5, 2):
57 state = rig.state()
58 rail = one_divider(state, 'beside')['rect']
59 x, y = rail['x'] + rail['w'] / 2, rail['y'] + rail['h'] / 4
60 rig.send('mousedown:' + rig.point(state, x, y))
61 rig.wait_state(lambda s: s['drag'] is not None)
62 scale(value)
63 state = rig.wait_state(lambda s: s['width'] == value * s['logical_width'])
64 require(state['drag'] is None, 'scale change retained stale drag capture')
65 before = edge(state, 'beside')
66 rig.send('mousemove:20,20', 'mouseup:20,20')
67 require(edge(rig.state(), 'beside') == before,
68 'stale motion changed divider after scale transition')
69 pty_sizes(rig, sessions, identities)
70 drag_and_keys(rig, identities, sessions)
71 pixels_and_batch(rig, identities, sessions)
72 (rig.root / 'scale-final.json').write_text(json.dumps(rig.state(), indent=2))
73 rig.quit()
74 print('PASS: Wayland 200% -> 100% -> 150% -> 200%; drag cancellation, '
75 'nested resize, PTYs and pixels;', rig.root, flush=True)
76 finally:
77 try:
78 rig.close()
79 finally:
80 scale(original_scale)
81
82
83 if __name__ == '__main__':
84 main()