a73x

d01b5506

feat: streamline native split chords and End feedback

a73x   2026-09-05 17:02

Commit message
feat: streamline native split chords and End feedback

RETRO.md
Old New
@@ -712,3 +712,43 @@ documentation handoff, and previous demo acceptance remains pending.
712 start internal daemon and GUI tasks in separate worktrees with these briefs. 712 start internal daemon and GUI tasks in separate worktrees with these briefs.
713 - [ ] Record user acceptance of the existing recordings separately. Continuing 713 - [ ] Record user acceptance of the existing recordings separately. Continuing
714 the ownership handoff does not imply their acceptance. 714 the ownership handoff does not imply their acceptance.
715
716 ## Native chord ergonomics — 2026-09-05
717
718 Hands-on feedback identified an unnecessary second chord after choosing split
719 direction, and a progress menu too brief to read on End. Prefix + v/b now opens
720 the host picker directly, keeping the selected direction and cancellation
721 semantics. End progress uses the originating pane header; shared-session refusal
722 still offers explicit force with Cancel selected first. Existing save warnings
723 retain header priority. The [delivery record](docs/superpowers/plans/2026-09-05-native-chord-ergonomics.md)
724 contains the validation and recording evidence.
725
726 The existing controller boundary made this a GUI-only change. Opening cleanup
727 was unnecessary; closing cleanup removed the obsolete progress-menu variant and
728 combined the shared v/b/Enter picker path. Video inspection caught the remaining
729 extra-chord header hint; review caught the distinction between insertion and
730 replacement pickers before final delivery. Stable attachment/request identity,
731 duplicate-End protection and late-reply modal ownership remain intact.
732
733 Luna implemented interaction/frame changes; Terra reviewed them adversarially.
734 Recorded metadata confirmed the models. Root updated integration fixtures,
735 independently observed delayed daemon replies and framebuffer pixels, and ran
736 the delivery gates. Final native units (50), native integration, full CI,
737 Wayland lifecycle/scale checks and raw-output stress passed. The stress result
738 remains close to the existing frame budget; it is not evidence of a speedup.
739 Available cumulative and start-snapshot-difference counters are retained with
740 cached input identified; they are not monetary costs.
741
742 The 23.8-second GUI recording shows the actual revised interactions. The user
743 explicitly approved its tailnet publication and confirmed ownership after
744 automatic review rejected the new payload; existing routes were preserved.
745 Playback and range responses were verified. Publication is separate from
746 acceptance of the ergonomic changes.
747
748 ### Next actions
749
750 - [ ] Record acceptance or further hands-on feedback for these chord changes.
751 - [ ] When revisiting header layout, consider showing save failure and pending
752 End together; keep save errors prominent in narrow panes.
753 - [ ] A future recorder package should include its PTY registration and absolute
754 compositor address setup, avoiding the two fixture mistakes caught here.
docs/superpowers/plans/2026-09-05-native-chord-ergonomics.md
Old New
@@ -0,0 +1,73 @@
1 # Native split chords and End feedback
2
3 The user reported that selecting a split direction is normally followed by host
4 selection, and that prefix + x flashes an unidentifiable menu. This hands-on
5 feedback authorizes a bounded ergonomic trial; it does not accept prior recordings.
6
7 ## Scope and acceptance
8
9 - Prefix + v/b arms the focused pane for side-by-side/below insertion and opens
10 the host picker immediately. No geometry or session changes before selection.
11 - Esc in the host picker closes it while retaining the pending direction;
12 another Esc outside the picker cancels it. Prefix + Enter remains available.
13 - End uses a status on its originating pane while awaiting the daemon. It opens
14 no progress modal. A shared-session refusal still offers Cancel first and
15 explicit End for all clients, bound to the original attachment.
16 - Pending requests cannot be duplicated; late replies cannot steal an active
17 picker, command, resize, drag or recovery menu. The daemon protocol is unchanged.
18
19 Opening inspection found the existing controller and pending request identity
20 sufficient; no preparatory refactor is needed. Closing cleanup removes the
21 obsolete progress-menu variant instead of introducing a popup delay or timer.
22 Existing save-failure visibility remains a priority in narrow pane headers.
23
24 Luna owns controller/frame changes and unit coverage; Terra reviews the input
25 and request-lifetime contracts. Root owns integration fixtures, independent
26 validation, the recording and delivery records. Reused session metadata confirms
27 the selected models. Test helpers must exercise the direct v/b-to-picker path
28 rather than sending an extra prefix + Enter afterward.
29
30 ## Validation and delivery
31
32 Implemented, independently reviewed and validated; recorded-demo acceptance is
33 pending. Both binaries were built together in ReleaseSafe at
34 `dist/native-chord-release`. The final GUI is also installed in `zig-out/bin/muxg`
35 for the user's next launch; an already running window keeps its earlier image.
36
37 - Native unit tests passed: 22 core and 28 frame/painter tests.
38 - Full `make ci` passed, including ten agent scenarios and throughput.
39 - `make native-e2e` passed on the final frozen source. The real delayed-daemon
40 cases assert no progress popup in modal state or centre framebuffer pixels,
41 preserve request identity, and exercise command, focus, picker, resize, drag,
42 replacement, detach and accepted-End transitions.
43 - Separate real Wayland lifecycle checks and 200%/100%/150%/200% scale transitions
44 passed, including independent kernel PTY dimensions and passive pixels.
45 - `make native-stress` passed after CI and recording completed. Over 30.2 seconds,
46 it read 104.8 MB, with frame p99 19.920 ms before reopen and 19.388 ms afterward.
47 Input-to-painted maxima were 55.5 ms on the separate daemon and 55.4 ms on the
48 shared daemon, 95 samples each. The 5 ms polling and observation overhead make
49 these upper bounds. No budget changed and no speed improvement is claimed.
50
51 The final 23.8-second actual GUI recording has one continuous clip, no cuts or
52 audio, at 200% scale. It shows both direct split chords, cancellation, fast End,
53 a delayed End header and Cancel-first shared-session confirmation. Input is
54 ordinary SDL events; this is not a new compositor mouse-path demonstration.
55 Video review caught an obsolete extra-chord hint; it now shows only the split
56 direction while the insertion picker is open. A replacement picker retains the
57 pending-arm hint, so it cannot be mislabeled as insertion.
58
59 Two fixture setup mistakes were corrected without product changes: the first
60 shell-suite Wayland launch lost its compositor address when the harness isolated
61 its runtime directory, and the first recorder omitted PTY observer registration.
62 The complete native suite used its standard offscreen setup; separate Python
63 Wayland fixtures preserve the compositor address correctly. Initial and final
64 logs are retained. The owned fixtures and compositor are cleaned up separately
65 from the user's sessions and desktop. No new macOS or SSH transport claim is made.
66
67 Artifacts, final source/binary hashes and model metadata are in `chord-evidence/`
68 beside this thread's visualization files. The retained video is
69 `native-chord-trial.mp4`. User approval explicitly confirmed tailnet ownership
70 and publication of this recording after automatic approval review blocked the
71 new payload. The verified page is
72 <https://charizard.folk-amberjack.ts.net/chord-trial/>; all prior routes remain.
73 Publication authorization does not imply demo acceptance.
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -77,8 +77,8 @@ Use side-by-side and above/below in the interface: `v` names the vertical
77 divider, with the new pane to the right; `b` puts the new pane below. Show the 77 divider, with the new pane to the right; `b` puts the new pane below. Show the
78 pending direction at its originating pane so the orientation is unambiguous. 78 pending direction at its originating pane so the orientation is unambiguous.
79 79
80 Direction selection arms the focused pane for one insertion and changes no 80 Direction selection arms the focused pane for one insertion, immediately opens
81 geometry. Opening the picker captures that pane and direction. Host selection 81 the host picker and changes no geometry. The picker captures that pane and direction. Host selection
82 opens its sessions; New session requests a name and creates it explicitly. 82 opens its sessions; New session requests a name and creates it explicitly.
83 Only committing a session selection inserts the pane and focuses it. Cancelling 83 Only committing a session selection inserts the pane and focuses it. Cancelling
84 either level before committing creates no pane or session. Esc in sessions returns to hosts; 84 either level before committing creates no pane or session. Esc in sessions returns to hosts;
@@ -125,8 +125,8 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI
125 125
126 | After prefix | Action | 126 | After prefix | Action |
127 | --- | --- | 127 | --- | --- |
128 | `v` | Arm side-by-side insertion (vertical divider) | 128 | `v` | Open host picker for side-by-side insertion (vertical divider) |
129 | `b` | Arm above/below insertion (new pane below) | 129 | `b` | Open host picker for above/below insertion (new pane below) |
130 | Enter | Open host picker for insertion | 130 | Enter | Open host picker for insertion |
131 | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right | 131 | `h` / `j` / `k` / `l`, or arrows | Focus left / down / up / right |
132 | `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 |
@@ -137,6 +137,9 @@ The prefix opens a small command hint strip; its next key is consumed by the GUI
137 137
138 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
139 `h/j/k/l` for directional movement, with `v` and `b` selecting split direction. 139 `h/j/k/l` for directional movement, with `v` and `b` selecting split direction.
140 The subsequent hands-on ergonomic trial makes `v`/`b` open host selection
141 immediately; a second prefix + Enter is unnecessary. Cancelling the host picker
142 retains the armed direction, and Esc outside the picker cancels it.
140 143
141 Pressing the prefix twice forwards its normal terminal keymap encoding once and 144 Pressing the prefix twice forwards its normal terminal keymap encoding once and
142 exits command mode. Ordinary input 145 exits command mode. Ordinary input
@@ -192,6 +195,10 @@ A lost or timed-out reply reports an unknown outcome. Retry establishes a fresh
192 attachment before another End may be issued. Recovery and confirmation retain 195 attachment before another End may be issued. Recovery and confirmation retain
193 their originating pane and attachment generation; confirmations identify the 196 their originating pane and attachment generation; confirmations identify the
194 session and target and cannot follow a later focus change to a different pane. 197 session and target and cannot follow a later focus change to a different pane.
198 The chord-ergonomics trial shows a pending End in its originating pane header,
199 without a progress popup. Normal input and GUI controls remain available; repeat
200 End requests are blocked until the outcome arrives. Existing save-failure
201 warnings retain header priority, so they can temporarily hide the pending marker.
195 202
196 Use a separate, versioned GUI state file at 203 Use a separate, versioned GUI state file at
197 `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered 204 `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered
src/gui/frame.zig
Old New
@@ -824,6 +824,32 @@ test "held detach removes one pane and recovery Enter stays consumed after closi
824 try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:")); 824 try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:"));
825 } 825 }
826 826
827 test "pending End header stays with its origin and rejects stale generations" {
828 const a = std.testing.allocator;
829 var rt = runtime.Runtime.init(a, .{});
830 defer rt.deinit();
831 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
832 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
833 const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
834 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
835 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 } };
836 defer events.deinit();
837 try events.ui.relayout();
838 const key = rt.get(first).?.key;
839 events.ui.pending_end = .{ .key = key, .request = 1 };
840 _ = rt.workspace.focus(second);
841 var origin_header: Header = .{};
842 origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?);
843 try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") != null);
844 var other_header: Header = .{};
845 other_header.set(&events, events.ui.layout.get(second).?, rt.get(second).?);
846 try std.testing.expect(std.mem.indexOf(u8, other_header.bytes[0..other_header.len], "End requested") == null);
847 events.ui.pending_end.?.key.generation += 1;
848 origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?);
849 try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") == null);
850 events.ui.pending_end = null;
851 }
852
827 const Header = struct { 853 const Header = struct {
828 bytes: [512]u8 = undefined, 854 bytes: [512]u8 = undefined,
829 cells: [512]term.grid.Cell = undefined, 855 cells: [512]term.grid.Cell = undefined,
@@ -831,7 +857,8 @@ const Header = struct {
831 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { 857 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
832 const focused = events.ui.rt.workspace.tab().focus == p.id; 858 const focused = events.ui.rt.workspace.tab().focus == p.id;
833 const pending = events.ui.rt.workspace.tab().pending; 859 const pending = events.ui.rt.workspace.tab().pending;
834 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; 860 const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false;
861 const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" 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 (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (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;
835 const label = events.ui.rt.workspace.pane(p.id).?.identity.label; 862 const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
836 var status_buf: [48]u8 = undefined; 863 var status_buf: [48]u8 = undefined;
837 const status: []const u8 = switch (live.status.phase) { 864 const status: []const u8 = switch (live.status.phase) {
src/gui/interaction.zig
Old New
@@ -24,7 +24,7 @@ pub const KeyDown = struct {
24 pub const PendingEnd = struct { key: model.Attachment, request: u64 }; 24 pub const PendingEnd = struct { key: model.Attachment, request: u64 };
25 25
26 pub const Recovery = struct { 26 pub const Recovery = struct {
27 kind: enum { recovery, ending, force_end }, 27 kind: enum { recovery, force_end },
28 key: model.Attachment, 28 key: model.Attachment,
29 selected: usize = 0, 29 selected: usize = 0,
30 notice: [1024]u8 = @splat(0), 30 notice: [1024]u8 = @splat(0),
@@ -32,21 +32,18 @@ pub const Recovery = struct {
32 pub fn count(self: Recovery) usize { 32 pub fn count(self: Recovery) usize {
33 return switch (self.kind) { 33 return switch (self.kind) {
34 .recovery => 3, 34 .recovery => 3,
35 .ending => 1,
36 .force_end => 2, 35 .force_end => 2,
37 }; 36 };
38 } 37 }
39 pub fn label(self: Recovery, index: usize) []const u8 { 38 pub fn label(self: Recovery, index: usize) []const u8 {
40 return switch (self.kind) { 39 return switch (self.kind) {
41 .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index], 40 .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], 41 .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index],
44 }; 42 };
45 } 43 }
46 pub fn title(self: Recovery) []const u8 { 44 pub fn title(self: Recovery) []const u8 {
47 return switch (self.kind) { 45 return switch (self.kind) {
48 .recovery => "Pane actions", 46 .recovery => "Pane actions",
49 .ending => "Ending session...",
50 .force_end => "Other clients are attached", 47 .force_end => "Other clients are attached",
51 }; 48 };
52 } 49 }
@@ -104,8 +101,14 @@ pub const Controller = struct {
104 ws.moveFocus(&self.layout, direction); 101 ws.moveFocus(&self.layout, direction);
105 self.intent_dirty = self.intent_dirty or before != ws.tab().focus; 102 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
106 } else switch (key) { 103 } else switch (key) {
107 .v => ws.arm(.beside), 104 .v, .b, .enter, .keypad_enter => {
108 .b => ws.arm(.stacked), 105 if (key == .v or key == .b) ws.arm(if (key == .v) .beside else .stacked);
106 self.openPicker(.insert) catch |err| {
107 self.notice = @errorName(err);
108 self.dirty = true;
109 return;
110 };
111 },
109 .r => self.resize_mode = true, 112 .r => self.resize_mode = true,
110 .d => if (ws.tab().focus) |id| try self.detach(id), 113 .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), 114 .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
@@ -114,13 +117,6 @@ pub const Controller = struct {
114 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); 117 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
115 }, 118 },
116 .escape => ws.cancel(), 119 .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", 120 else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add",
125 } 121 }
126 self.dirty = true; 122 self.dirty = true;
@@ -180,7 +176,6 @@ pub const Controller = struct {
180 return; 176 return;
181 }; 177 };
182 self.pending_end = .{ .key = key, .request = request }; 178 self.pending_end = .{ .key = key, .request = request };
183 self.recovery = .{ .kind = .ending, .key = key };
184 self.dirty = true; 179 self.dirty = true;
185 } 180 }
186 pub fn pollEnd(self: *Controller) !void { 181 pub fn pollEnd(self: *Controller) !void {
@@ -193,8 +188,6 @@ pub const Controller = struct {
193 const result = self.rt.get(pending.key.pane).?.status.ending; 188 const result = self.rt.get(pending.key.pane).?.status.ending;
194 if (result.request != pending.request or result.phase == .pending) return; 189 if (result.request != pending.request or result.phase == .pending) return;
195 self.pending_end = null; 190 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) { 191 switch (result.phase) {
199 .accepted => try self.detach(pending.key.pane), 192 .accepted => try self.detach(pending.key.pane),
200 .refused => { 193 .refused => {
@@ -218,7 +211,6 @@ pub const Controller = struct {
218 return; 211 return;
219 } 212 }
220 switch (menu.kind) { 213 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), 214 .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true),
223 .recovery => switch (menu.selected) { 215 .recovery => switch (menu.selected) {
224 0, 1 => { 216 0, 1 => {
@@ -498,3 +490,44 @@ test "controller modal input takes precedence over the prefix" {
498 try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true }); 490 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); 491 try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text);
500 } 492 }
493
494 test "v/b opens insertion picker and Esc preserves then cancels pending direction" {
495 var rt = runtime.Runtime.init(std.testing.allocator, .{});
496 defer rt.deinit();
497 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
498 defer ui.deinit();
499
500 _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics);
501 try ui.command(.b);
502 try std.testing.expect(ui.picker != null);
503 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
504 try ui.keyDown(.{ .code = 27, .kind = .escape });
505 ui.keyUp(27);
506 try std.testing.expect(ui.picker == null);
507 try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
508
509 try ui.keyDown(.{ .code = 27, .kind = .escape });
510 ui.keyUp(27);
511 try std.testing.expect(rt.workspace.tab().pending == null);
512
513 try ui.command(.v);
514 try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction);
515 try ui.keyDown(.{ .code = 27, .kind = .escape });
516 ui.keyUp(27);
517 }
518
519 test "beginEnd records pending request without opening an ending modal" {
520 var rt = runtime.Runtime.init(std.testing.allocator, .{});
521 defer rt.deinit();
522 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
523 const id = try rt.add(.{ .via = "cat" }, "ending", 800, 600, metrics);
524 var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
525 defer ui.deinit();
526 rt.get(id).?.pump.mu.lock();
527 rt.get(id).?.pump.status.phase = .attached;
528 rt.get(id).?.pump.mu.unlock();
529 try ui.beginEnd(rt.get(id).?.key, false);
530 try std.testing.expect(ui.recovery == null);
531 try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request);
532 ui.pending_end = null;
533 }
test/native_lifecycle.py
Old New
@@ -15,7 +15,7 @@ import time
15 sys.dont_write_bytecode = True 15 sys.dont_write_bytecode = True
16 from native_tiling import colour_counts, eventually, require 16 from native_tiling import colour_counts, eventually, require
17 from native_resize import ResizeRig, by_id, edge 17 from native_resize import ResizeRig, by_id, edge
18 from native_picker import create, end, sessions 18 from native_picker import create, end, region, sessions
19 19
20 20
21 class LifecycleRig(ResizeRig): 21 class LifecycleRig(ResizeRig):
@@ -501,12 +501,23 @@ def delayed_end_transitions(mux, muxg):
501 generation = by_id(state)[origin]['generation'] 501 generation = by_id(state)[origin]['generation']
502 holder = None if transition == 'accepted' else rig.holder(a, 'left') 502 holder = None if transition == 'accepted' else rig.holder(a, 'left')
503 rig.focus(origin) 503 rig.focus(origin)
504 # A delayed reply makes any transient progress popup observable.
505 # Compare the quiet centre of the actual framebuffer, away from
506 # pane headers and shell cursors, as well as modal state.
507 state = rig.state()
508 centre = {'x': state['width'] // 2 - 40, 'y': state['height'] // 2 - 30,
509 'w': 80, 'h': 60}
510 before_pixels = region(rig.pixels(), centre)
504 daemon.send_signal(signal.SIGSTOP) 511 daemon.send_signal(signal.SIGSTOP)
505 paused = daemon 512 paused = daemon
506 rig.chord('x') 513 rig.chord('x')
507 rig.wait_state(lambda s: s['pending_end'] is not None) 514 state = rig.wait_state(lambda s: s['pending_end'] is not None)
508 rig.key('escape') 515 require(state['recovery'] is None and state['picker'] is None,
509 rig.wait_state(lambda s: s['recovery'] is None) 516 'pending End opened a progress popup')
517 require(state['pending_end']['key']['pane'] == origin,
518 'pending End lost its original target')
519 require(region(rig.pixels(), centre) == before_pixels,
520 'pending End painted a popup over the workspace')
510 521
511 if transition in ('focus', 'accepted'): 522 if transition in ('focus', 'accepted'):
512 rig.focus(neighbour) 523 rig.focus(neighbour)
@@ -560,6 +571,8 @@ def delayed_end_transitions(mux, muxg):
560 require(state['recovery'] is None, 'late refusal installed a force menu in another context') 571 require(state['recovery'] is None, 'late refusal installed a force menu in another context')
561 if transition == 'command': 572 if transition == 'command':
562 require(state['command_mode'], 'late refusal interrupted command mode') 573 require(state['command_mode'], 'late refusal interrupted command mode')
574 elif transition == 'focus':
575 require(state['focus'] == neighbour, 'late refusal stole focus from the neighbour')
563 elif transition == 'picker': 576 elif transition == 'picker':
564 require(state['picker'] is not None, 'late refusal dismissed the picker') 577 require(state['picker'] is not None, 'late refusal dismissed the picker')
565 elif transition == 'resize': 578 elif transition == 'resize':
test/native_picker.py
Old New
@@ -222,7 +222,6 @@ def add_host(rig):
222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") 222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host")
223 before = rig.pixels() 223 before = rig.pixels()
224 rig.chord("v") 224 rig.chord("v")
225 rig.chord("enter")
226 state = rig.picker("hosts") 225 state = rig.picker("hosts")
227 rect = state["picker"]["rect"] 226 rect = state["picker"]["rect"]
228 painted = rig.pixels() 227 painted = rig.pixels()
@@ -261,7 +260,6 @@ def slow_and_legacy(rig):
261 rig.catalogue([first_target, slow_target, legacy_target]) 260 rig.catalogue([first_target, slow_target, legacy_target])
262 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") 261 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker")
263 rig.chord("v") 262 rig.chord("v")
264 rig.chord("enter")
265 rig.picker("hosts") 263 rig.picker("hosts")
266 rig.choose(slow_target) 264 rig.choose(slow_target)
267 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") 265 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried")
test/native_tiling.py
Old New
@@ -176,7 +176,8 @@ class Rig:
176 def host(self, spelling, axis=None): 176 def host(self, spelling, axis=None):
177 if axis: 177 if axis:
178 self.chord(axis) 178 self.chord(axis)
179 self.chord("enter") 179 else:
180 self.chord("enter")
180 self.picker("hosts") 181 self.picker("hosts")
181 self.choose(spelling) 182 self.choose(spelling)
182 self.picker("sessions") 183 self.picker("sessions")
@@ -301,7 +302,9 @@ def normal_scenario(rig, axis):
301 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") 302 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets")
302 before = rig.start_gui(first, second, "gui-" + axis) 303 before = rig.start_gui(first, second, "gui-" + axis)
303 rig.chord(axis) 304 rig.chord(axis)
304 armed = rig.state() 305 armed = rig.picker("hosts")
306 require(armed["pending"]["direction"] == ("beside" if axis == "v" else "stacked"),
307 "split chord opened picker with the wrong direction")
305 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], 308 require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"],
306 "arming split changed geometry") 309 "arming split changed geometry")
307 stats = rig.command("d", "stats", "--sock", second).stdout 310 stats = rig.command("d", "stats", "--sock", second).stdout
@@ -312,10 +315,13 @@ def normal_scenario(rig, axis):
312 require(rig.command("a", "status", "--sock", second, "--session", "right", 315 require(rig.command("a", "status", "--sock", second, "--session", "right",
313 "--timeout", "200", check=False).returncode != 0, 316 "--timeout", "200", check=False).returncode != 0,
314 "staged named session exists before insertion") 317 "staged named session exists before insertion")
315 rig.chord("escape") 318 rig.key("escape")
316 require(len(rig.state()["panes"]) == 1, "cancel inserted a pane") 319 cancelled = rig.wait_state(lambda s: s["picker"] is None)
320 require(len(cancelled["panes"]) == 1 and cancelled["pending"] == armed["pending"],
321 "closing host picker changed panes or forgot its armed split")
322 rig.key("escape")
323 rig.wait_state(lambda s: s["pending"] is None)
317 rig.chord(axis) 324 rig.chord(axis)
318 rig.chord("enter")
319 rig.picker("hosts") 325 rig.picker("hosts")
320 rig.choose(rig.targets.get(second, "--sock " + second)) 326 rig.choose(rig.targets.get(second, "--sock " + second))
321 rig.picker("sessions") 327 rig.picker("sessions")