a73x

d1ab8e37

refactor: merge reusable picker presentation

a73x   2026-09-09 13:57

Commit message
refactor: merge reusable picker presentation

CLAUDE.md
Old New
@@ -16,7 +16,10 @@ edits do not require that workflow.
16 16
17 ## Toolchain and gates 17 ## Toolchain and gates
18 18
19 Use `deps/zig/zig` (0.15.2) through Make; the system Zig is incompatible. 19 Use `mise` (pinned to Zig 0.15.2 in `mise.toml`) through Make; Make resolves
20 `ZIG` via `mise which zig` and respects an explicit `ZIG=/path/to/zig`
21 override. `mise install` provisions this compiler per machine; worktrees reuse
22 it, so `deps/zig` is not required. The system Zig is incompatible.
20 Make handles the macOS shadow SDK. See [development commands](docs/development.md) 23 Make handles the macOS shadow SDK. See [development commands](docs/development.md)
21 for setup, releases, cross-OS checks and the full command list. 24 for setup, releases, cross-OS checks and the full command list.
22 25
docs/component-ownership.md
Old New
@@ -57,12 +57,16 @@ semantics, or the meaning of an error shared across frontends.
57 57
58 Start from the current checkout and read `CLAUDE.md`, the relevant component 58 Start from the current checkout and read `CLAUDE.md`, the relevant component
59 header, and the affected contract or protocol comments. Use an isolated 59 header, and the affected contract or protocol comments. Use an isolated
60 worktree when another component is active, after ensuring its ignored pinned 60 worktree when another component is active, after ensuring its pinned toolchain
61 toolchain (`deps/zig`) and generated dependency inputs (`deps/quic/out` and 61 (via `mise install` for the Zig pin in `mise.toml`) is available. The QUIC
62 `deps/quic/work`) are available. The QUIC build script itself is tracked. 62 build script itself is tracked.
63 Use worktree-local generated dependencies when rebuilding them; do not run 63 Use worktree-local generated dependencies when rebuilding them; if another
64 concurrent builders or cleanup against shared dependency directories. Build 64 worktree at the same revision/platform has matching `deps/quic/out`, you can
65 with the pinned toolchain through Make so macOS SDK setup is retained. For 65 copy it in to avoid re-downloading, but keep `deps/quic/work` local per-worktree.
66 That workspace
67 contains absolute-path build caches and should not be shared by mutable symlinks.
68 Do not run concurrent builders or cleanup against shared dependency directories.
69 Build with the pinned toolchain through Make so macOS SDK setup is retained. For
66 example, a daemon task can use: 70 example, a daemon task can use:
67 71
68 ```sh 72 ```sh
docs/development.md
Old New
@@ -1,12 +1,11 @@
1 # Development commands 1 # Development commands
2 2
3 ```sh 3 ```sh
4 ZIG=deps/zig/zig # vendored 0.15.2 (ghostty pin); copy the toolchain here, gitignored 4 mise install # install/pin Zig 0.15.2 from mise.toml; one host install is shared across worktrees
5 # on a Mac that is the aarch64 0.15.2 tarball, in the same place
6 make mac-sdk # Darwin only, and `make build` runs it: a shadow SDK 5 make mac-sdk # Darwin only, and `make build` runs it: a shadow SDK
7 # under deps/mac-sdk, because zig 0.15.2 cannot link 6 # under deps/mac-sdk, because zig 0.15.2 cannot link
8 # the stub Xcode 26.4+ ships. No-op elsewhere. 7 # the stub Xcode 26.4+ ships. No-op elsewhere.
9 make build test e2e # build lints first; Makefile already points at the pinned Zig 8 make build test e2e # build lints first; Makefile resolves ZIG via mise (or `ZIG=/path/to/zig`)
10 make lint # standalone: fetch/verify pinned zlint and lint Zig sources 9 make lint # standalone: fetch/verify pinned zlint and lint Zig sources
11 make check # lint + fmt + tests + shell/web ABI/source checks — pre-commit gate 10 make check # lint + fmt + tests + shell/web ABI/source checks — pre-commit gate
12 make ci # check + e2e + agent + throughput — the delivery gate 11 make ci # check + e2e + agent + throughput — the delivery gate
@@ -33,7 +32,13 @@ make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at
33 ``` 32 ```
34 33
35 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once). 34 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once).
36 `make clean` deliberately spares `deps/quic`. 35 `deps/quic/out` holds the generated link-time outputs; `deps/quic/work` is the
36 local build workspace and cache. In multi-worktree workflows, you can copy a
37 same-revision/same-platform `deps/quic/out` tree from another worktree to avoid
38 re-downloading, but keep `deps/quic/work` local per-worktree: it contains
39 absolute-path build caches and should not be shared via mutable symlinks.
40 `make clean-deps` removes `deps/quic/out` and `deps/quic/work`; `make clean`
41 deliberately spares `deps/quic`.
37 42
38 ## Lint dependencies 43 ## Lint dependencies
39 44
src/gui/frame.zig
Old New
@@ -10,6 +10,7 @@ const runtime = native_core.runtime;
10 const picker_mod = native_core.picker; 10 const picker_mod = native_core.picker;
11 const persistence = native_core.persistence; 11 const persistence = native_core.persistence;
12 const interaction = native_core.interaction; 12 const interaction = native_core.interaction;
13 const popover = native_core.popover;
13 const font = @import("font.zig"); 14 const font = @import("font.zig");
14 const atlas = @import("atlas.zig"); 15 const atlas = @import("atlas.zig");
15 const quads = @import("quads.zig"); 16 const quads = @import("quads.zig");
@@ -743,7 +744,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
743 } 744 }
744 popup.len = 0; 745 popup.len = 0;
745 if (events.ui.modalPicker()) |picker| { 746 if (events.ui.modalPicker()) |picker| {
746 popup.set(picker); 747 popup.setPopover(picker.layout(), picker.presentation.view());
747 } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events); 748 } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events);
748 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); 749 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);
749 if (events.ui.modalPicker() != 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); 750 if (events.ui.modalPicker() != 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);
@@ -878,7 +879,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
878 try events.ui.pollEnd(); 879 try events.ui.pollEnd();
879 try std.testing.expect(events.ui.recovery == null and events.ui.modalPicker() != null); 880 try std.testing.expect(events.ui.recovery == null and events.ui.modalPicker() != null);
880 try std.testing.expect(events.ui.pending_end == null); 881 try std.testing.expect(events.ui.pending_end == null);
881 events.ui.modalPicker().?.level = .session_name; 882 try events.ui.modalPicker().?.show(.session_name);
882 var ev = std.mem.zeroes(c.SDL_Event); 883 var ev = std.mem.zeroes(c.SDL_Event);
883 ev.key.type = c.SDL_EVENT_KEY_DOWN; 884 ev.key.type = c.SDL_EVENT_KEY_DOWN;
884 ev.key.key = c.SDLK_X; 885 ev.key.key = c.SDLK_X;
@@ -886,7 +887,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick
886 ev.text.type = c.SDL_EVENT_TEXT_INPUT; 887 ev.text.type = c.SDL_EVENT_TEXT_INPUT;
887 ev.text.text = "x"; 888 ev.text.text = "x";
888 _ = try events.handle(ev); 889 _ = try events.handle(ev);
889 try std.testing.expectEqualStrings("x", events.ui.modalPicker().?.input.items); 890 try std.testing.expectEqualStrings("x", events.ui.modalPicker().?.presentation.input.items);
890 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request); 891 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request);
891 } 892 }
892 893
@@ -1105,10 +1106,10 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
1105 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 }; 1106 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 };
1106 var picker_state: ?PopupState = null; 1107 var picker_state: ?PopupState = null;
1107 if (events.ui.modalPicker()) |picker| { 1108 if (events.ui.modalPicker()) |picker| {
1108 const view = picker.view(); 1109 const view = picker.layout();
1109 const rows = try a.alloc(PopupRow, picker.rowCount()); 1110 const rows = try a.alloc(PopupRow, picker.presentation.owned.rows.items.len);
1110 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; 1111 for (rows, 0..) |*row, i| row.* = .{ .label = picker.presentation.owned.rows.items[i].label, .rect = .{ .x = view.rowRect(i).x, .y = view.rowRect(i).y, .w = view.rowRect(i).w, .h = view.rowRect(i).h } };
1111 picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.selected, .notice = picker.noticeText(), .host = picker.host(), .input = picker.input.items, .rect = view.rect, .row_height = view.row_height, .first = view.first }; 1112 picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.presentation.selected orelse 0, .notice = picker.noticeText(), .host = picker.host(), .input = picker.presentation.input.items, .rect = .{ .x = view.rect.x, .y = view.rect.y, .w = view.rect.w, .h = view.rect.h }, .row_height = view.row_height, .first = view.first };
1112 } 1113 }
1113 const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect }; 1114 const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect };
1114 var recovery_state: ?RecoveryState = null; 1115 var recovery_state: ?RecoveryState = null;
@@ -1491,29 +1492,29 @@ const PopupFrame = struct {
1491 self.lines[2].setText(events.ui.notice, cols); 1492 self.lines[2].setText(events.ui.notice, cols);
1492 self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols); 1493 self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols);
1493 } 1494 }
1494 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void { 1495 fn setPopover(self: *PopupFrame, layout_: popover.Layout, view: popover.Presentation) void {
1495 const view = picker.view(); 1496 const cols = self.reset(.{ .x = layout_.rect.x, .y = layout_.rect.y, .w = layout_.rect.w, .h = layout_.rect.h }, layout_.shown + 4, layout_.cell_width);
1496 const cols = self.reset(view.rect, view.shown + 4, picker.metrics.cell_w); 1497 self.lines[0].setText(view.title, cols);
1497 self.lines[0].setText(picker.title(), cols); 1498 for (0..layout_.shown) |i| {
1498 for (0..view.shown) |i| { 1499 const index = layout_.first + i;
1499 const index = view.first + i; 1500 self.lines[i + 1].setText(view.rows[index].label, cols);
1500 self.lines[i + 1].setText(picker.rowLabel(index), cols); 1501 if (view.selected == index) self.selected_line = i + 1;
1501 if (index == picker.selected) self.selected_line = i + 1;
1502 } 1502 }
1503 const editing = picker.level == .host_name or picker.level == .session_name; 1503 if (view.editor) |editor| {
1504 if (editing) {
1505 // Keep the caret end visible while preserving the complete editor 1504 // Keep the caret end visible while preserving the complete editor
1506 // buffer; the actual selection/name does not truncate with a row. 1505 // buffer; the actual selection/name does not truncate with a row.
1507 var start = picker.input.items.len -| cols; 1506 var start = editor.len -| cols;
1508 while (start < picker.input.items.len and picker.input.items[start] & 0xc0 == 0x80) start += 1; 1507 while (start < editor.len and editor[start] & 0xc0 == 0x80) start += 1;
1509 self.lines[1].setText(picker.input.items[start..], cols); 1508 self.lines[1].setText(editor[start..], cols);
1510 self.selected_line = 1; 1509 self.selected_line = 1;
1511 } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols); 1510 } else self.lines[self.len - 3].setText(view.context, cols);
1512 self.lines[self.len - 2].setText(picker.noticeText(), cols); 1511 self.lines[self.len - 2].setText(view.notice, cols);
1513 self.lines[self.len - 1].setText(if (editing) "Enter confirms | Esc goes back" else if (picker.mode == .insert) "j/k choose | Enter selects | v below, b beside | Esc back" else "Up/Down or j/k choose | Enter selects | Esc goes back", cols); 1512 self.lines[self.len - 1].setText(view.hint, cols);
1514 } 1513 }
1515 fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { 1514 fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void {
1516 if (self.len == 0) return; 1515 if (self.len == 0) return;
1516 const bg_start = lists.backgrounds.items.len;
1517 const fg_start = lists.foregrounds.items.len;
1517 try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), base.theme.modal_bg)); 1518 try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), base.theme.modal_bg));
1518 for (self.lines[0..self.len], 0..) |*line, i| { 1519 for (self.lines[0..self.len], 0..) |*line, i| {
1519 var ctx = base; 1520 var ctx = base;
@@ -1526,7 +1527,7 @@ const PopupFrame = struct {
1526 var row = line.row(); 1527 var row = line.row();
1527 _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx); 1528 _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx);
1528 } 1529 }
1529 clipPane(lists, 0, 0, self.rect); 1530 clipPane(lists, bg_start, fg_start, self.rect);
1530 } 1531 }
1531 }; 1532 };
1532 1533
@@ -1539,13 +1540,13 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1539 rt.workspace.arm(.beside); 1540 rt.workspace.arm(.beside);
1540 var next: u64 = 2; 1541 var next: u64 = 2;
1541 const picker = try a.create(picker_mod.Picker); 1542 const picker = try a.create(picker_mod.Picker);
1542 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 }; 1543 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, .presentation = try popover.State.init(a, .{}) };
1543 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; 1544 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1544 var events = testEvents(&rt, &wake, metrics, 800, 600); 1545 var events = testEvents(&rt, &wake, metrics, 800, 600);
1545 events.ui.opening = picker; 1546 events.ui.opening = picker;
1546 defer events.deinit(); 1547 defer events.deinit();
1547 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); 1548 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
1548 picker.level = .session_name; 1549 try picker.show(.session_name);
1549 var event = std.mem.zeroes(c.SDL_Event); 1550 var event = std.mem.zeroes(c.SDL_Event);
1550 event.key.type = c.SDL_EVENT_KEY_DOWN; 1551 event.key.type = c.SDL_EVENT_KEY_DOWN;
1551 event.key.key = c.SDLK_J; 1552 event.key.key = c.SDLK_J;
@@ -1553,7 +1554,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1553 event.text.type = c.SDL_EVENT_TEXT_INPUT; 1554 event.text.type = c.SDL_EVENT_TEXT_INPUT;
1554 event.text.text = "j"; 1555 event.text.text = "j";
1555 try std.testing.expect(try events.handle(event)); 1556 try std.testing.expect(try events.handle(event));
1556 try std.testing.expectEqualStrings("j", picker.input.items); 1557 try std.testing.expectEqualStrings("j", picker.presentation.input.items);
1557 // Modifier chords belong to the modal, even double-prefix. Neither the 1558 // Modifier chords belong to the modal, even double-prefix. Neither the
1558 // editor nor the terminal receives its associated text. 1559 // editor nor the terminal receives its associated text.
1559 event = std.mem.zeroes(c.SDL_Event); 1560 event = std.mem.zeroes(c.SDL_Event);
@@ -1563,10 +1564,10 @@ test "modal Enter remains consumed through insertion and repeated keydown until
1563 try std.testing.expect(try events.handle(event)); 1564 try std.testing.expect(try events.handle(event));
1564 try std.testing.expect(try events.handle(event)); 1565 try std.testing.expect(try events.handle(event));
1565 try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text); 1566 try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text);
1566 picker.level = .sessions;
1567 picker.session_count = 1; 1567 picker.session_count = 1;
1568 picker.sessions[0][0] = 'x'; 1568 picker.sessions[0][0] = 'x';
1569 picker.session_lens[0] = 1; 1569 picker.session_lens[0] = 1;
1570 try picker.show(.sessions);
1570 event = std.mem.zeroes(c.SDL_Event); 1571 event = std.mem.zeroes(c.SDL_Event);
1571 event.key.type = c.SDL_EVENT_KEY_DOWN; 1572 event.key.type = c.SDL_EVENT_KEY_DOWN;
1572 event.key.key = c.SDLK_RETURN; 1573 event.key.key = c.SDLK_RETURN;
src/gui/interaction.zig
Old New
@@ -35,6 +35,16 @@ pub const Wheel = struct {
35 }; 35 };
36 36
37 pub const Recovery = struct { 37 pub const Recovery = struct {
38 pub const View = struct {
39 rect: model.Rect,
40 first: usize,
41 shown: usize,
42 row_height: u16,
43 pub fn rowRect(self: View, index: usize) model.Rect {
44 if (index < self.first or index >= self.first + self.shown) return .{};
45 return model.Rect.intersect(.{ .x = self.rect.x, .y = self.rect.y + self.row_height * @as(u32, @intCast(1 + index - self.first)), .w = self.rect.w, .h = self.row_height }, self.rect);
46 }
47 };
38 kind: enum { recovery, force_end }, 48 kind: enum { recovery, force_end },
39 key: model.Attachment, 49 key: model.Attachment,
40 selected: usize = 0, 50 selected: usize = 0,
@@ -62,7 +72,7 @@ pub const Recovery = struct {
62 self.notice_len = @min(text.len, self.notice.len); 72 self.notice_len = @min(text.len, self.notice.len);
63 @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]); 73 @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]);
64 } 74 }
65 pub fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) picker_mod.View { 75 pub fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) View {
66 const w = @min(width, @as(u32, metrics.cell_w) * 74); 76 const w = @min(width, @as(u32, metrics.cell_w) * 74);
67 const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4))); 77 const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4)));
68 return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h }; 78 return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h };
@@ -113,6 +123,7 @@ pub const Controller = struct {
113 opening.cancel(); 123 opening.cancel();
114 if (opening.mode == .quick) std.debug.print("muxg: {s}\n", .{opening.noticeText()}); 124 if (opening.mode == .quick) std.debug.print("muxg: {s}\n", .{opening.noticeText()});
115 opening.deinit(); 125 opening.deinit();
126 self.opening = null;
116 } 127 }
117 } 128 }
118 /// The modal view of the active request; quick opening leaves input live. 129 /// The modal view of the active request; quick opening leaves input live.
@@ -121,7 +132,7 @@ pub const Controller = struct {
121 return if (opening.mode == .quick) null else opening; 132 return if (opening.mode == .quick) null else opening;
122 } 133 }
123 pub fn hasPointerCapture(self: *const Controller) bool { 134 pub fn hasPointerCapture(self: *const Controller) bool {
124 return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; 135 return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held or (if (self.modalPicker()) |picker| picker.presentation.pressing() else false);
125 } 136 }
126 pub fn cancelMouse(self: *Controller) void { 137 pub fn cancelMouse(self: *Controller) void {
127 if (self.app_drag) |app| { 138 if (self.app_drag) |app| {
@@ -269,13 +280,13 @@ pub const Controller = struct {
269 const opening = self.opening orelse return; 280 const opening = self.opening orelse return;
270 if (!opening.closed) return; 281 if (!opening.closed) return;
271 const inserted = opening.inserted; 282 const inserted = opening.inserted;
272 if (opening.mode == .quick) { 283 if (inserted) {
273 if (inserted) { 284 self.notice = "";
274 self.notice = ""; 285 } else {
275 } else { 286 // A dismiss may follow an uncertain create. Keep that outcome
276 self.setNotice(opening.noticeText()); 287 // visible after the presentation is gone, never imply a retry.
277 if (self.rt.workspace.active_tab_id == opening.origin_tab and std.meta.eql(self.rt.workspace.tab().pending, opening.pending)) self.rt.workspace.cancel(); 288 self.setNotice(opening.noticeText());
278 } 289 if (opening.mode == .quick and self.rt.workspace.active_tab_id == opening.origin_tab and std.meta.eql(self.rt.workspace.tab().pending, opening.pending)) self.rt.workspace.cancel();
279 } 290 }
280 opening.deinit(); 291 opening.deinit();
281 self.opening = null; 292 self.opening = null;
@@ -424,7 +435,7 @@ pub const Controller = struct {
424 break; 435 break;
425 }; 436 };
426 } else if (self.modalPicker()) |picker| { 437 } else if (self.modalPicker()) |picker| {
427 try picker.click(x, y); 438 picker.pointerDown(x, y);
428 try self.finishOpening(); 439 try self.finishOpening();
429 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { 440 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
430 const d = self.layout.divider(id).?; 441 const d = self.layout.divider(id).?;
@@ -502,6 +513,13 @@ pub const Controller = struct {
502 } 513 }
503 514
504 pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: app_input.Mods) !void { 515 pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: app_input.Mods) !void {
516 if (self.modalPicker()) |picker| {
517 if (button != 0) return;
518 try picker.pointerUp(x, y);
519 try self.finishOpening();
520 self.dirty = true;
521 return;
522 }
505 if (self.app_drag) |app| { 523 if (self.app_drag) |app| {
506 if (button != app.button) return; 524 if (button != app.button) return;
507 if (!self.rt.accepts(app.key)) return self.cancelMouse(); 525 if (!self.rt.accepts(app.key)) return self.cancelMouse();
@@ -675,6 +693,7 @@ pub const Controller = struct {
675 self.command_mode = false; 693 self.command_mode = false;
676 self.resize_mode = false; 694 self.resize_mode = false;
677 self.modal_held.clearRetainingCapacity(); 695 self.modal_held.clearRetainingCapacity();
696 if (self.modalPicker()) |picker| picker.cancelPress();
678 self.cancelDrag(); 697 self.cancelDrag();
679 self.clearSelection(); 698 self.clearSelection();
680 self.consumed_key = null; 699 self.consumed_key = null;
@@ -684,6 +703,7 @@ pub const Controller = struct {
684 if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) { 703 if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) {
685 self.cancelDrag(); 704 self.cancelDrag();
686 self.clearSelection(); 705 self.clearSelection();
706 if (self.modalPicker()) |picker| picker.cancelPress();
687 } 707 }
688 self.fb_w = w; 708 self.fb_w = w;
689 self.fb_h = h; 709 self.fb_h = h;
@@ -836,6 +856,41 @@ test "copy shortcut owns its physical key until release" {
836 try std.testing.expect(ui.consumed_key == null and !ui.suppress_text); 856 try std.testing.expect(ui.consumed_key == null and !ui.suppress_text);
837 } 857 }
838 858
859 test "picker press is cancelled by geometry changes and focus loss" {
860 var rt = runtime.Runtime.init(std.testing.allocator, .{});
861 defer rt.deinit();
862 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
863 var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
864 defer ui.deinit();
865 var next: u64 = 2;
866 const opening = try std.testing.allocator.create(picker_mod.Picker);
867 opening.* = .{ .alloc = std.testing.allocator, .arena = std.heap.ArenaAllocator.init(std.testing.allocator), .rt = &rt, .origin = null, .origin_tab = rt.workspace.active_tab_id, .pending = null, .ticket = .{ .generation = 1, .owner = 0, .attachment_generation = 0 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null, .presentation = try @import("popover.zig").State.init(std.testing.allocator, .{}) };
868 try opening.show(.hosts);
869 ui.opening = opening;
870 const picker = ui.modalPicker().?;
871 const row = picker.layout().rowRect(0);
872 try ui.mouseDown(row.x, row.y, 0, 0, 0, .{});
873 try std.testing.expect(picker.presentation.pressing());
874 try ui.updateGeometry(799, 600, metrics);
875 try ui.mouseUp(row.x, row.y, 0, .{});
876 try std.testing.expect(ui.modalPicker().?.level == .hosts);
877 const retry_row = ui.modalPicker().?.layout().rowRect(0);
878 try ui.mouseDown(retry_row.x, retry_row.y, 0, 0, 0, .{});
879 ui.focusLost();
880 try ui.mouseUp(retry_row.x, retry_row.y, 0, .{});
881 try std.testing.expect(ui.modalPicker().?.level == .hosts);
882 }
883
884 test "controller teardown clears a freed picker before capture is queried" {
885 var rt = runtime.Runtime.init(std.testing.allocator, .{});
886 defer rt.deinit();
887 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
888 try ui.open(.insert);
889 try std.testing.expect(ui.modalPicker() != null);
890 ui.deinit();
891 try std.testing.expect(ui.opening == null and !ui.hasPointerCapture());
892 }
893
839 test "paste shortcut follows the active text destination" { 894 test "paste shortcut follows the active text destination" {
840 const a = std.testing.allocator; 895 const a = std.testing.allocator;
841 var rt = runtime.Runtime.init(a, .{}); 896 var rt = runtime.Runtime.init(a, .{});
@@ -843,14 +898,14 @@ test "paste shortcut follows the active text destination" {
843 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; 898 var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
844 defer ui.deinit(); 899 defer ui.deinit();
845 var next: u64 = 2; 900 var next: u64 = 2;
846 var picker: picker_mod.Picker = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = null, .origin_tab = rt.workspace.active_tab_id, .pending = null, .ticket = .{ .generation = 1, .owner = 0, .attachment_generation = 0 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = ui.metrics, .wake = null, .wake_ctx = null }; 901 var picker: picker_mod.Picker = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = null, .origin_tab = rt.workspace.active_tab_id, .pending = null, .ticket = .{ .generation = 1, .owner = 0, .attachment_generation = 0 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = ui.metrics, .wake = null, .wake_ctx = null, .presentation = try @import("popover.zig").State.init(a, .{}) };
847 defer picker.arena.deinit(); 902 defer picker.arena.deinit();
848 defer picker.input.deinit(a); 903 defer picker.presentation.deinit();
849 picker.level = .session_name; 904 try picker.show(.session_name);
850 ui.opening = &picker; 905 ui.opening = &picker;
851 defer ui.opening = null; 906 defer ui.opening = null;
852 try ui.pasteShortcut(86, "new-shell"); 907 try ui.pasteShortcut(86, "new-shell");
853 try std.testing.expectEqualStrings("new-shell", picker.input.items); 908 try std.testing.expectEqualStrings("new-shell", picker.presentation.input.items);
854 try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key); 909 try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key);
855 ui.keyUp(86); 910 ui.keyUp(86);
856 ui.opening = null; 911 ui.opening = null;
src/gui/native_core.zig
Old New
@@ -8,6 +8,7 @@ const term = @import("term");
8 pub const workspace = @import("workspace.zig"); 8 pub const workspace = @import("workspace.zig");
9 pub const runtime = @import("runtime.zig"); 9 pub const runtime = @import("runtime.zig");
10 pub const picker = @import("picker.zig"); 10 pub const picker = @import("picker.zig");
11 pub const popover = @import("popover.zig");
11 pub const persistence = @import("persistence.zig"); 12 pub const persistence = @import("persistence.zig");
12 pub const interaction = @import("interaction.zig"); 13 pub const interaction = @import("interaction.zig");
13 pub const config = @import("config.zig"); 14 pub const config = @import("config.zig");
@@ -20,6 +21,7 @@ test {
20 _ = workspace; 21 _ = workspace;
21 _ = runtime; 22 _ = runtime;
22 _ = picker; 23 _ = picker;
24 _ = popover;
23 _ = persistence; 25 _ = persistence;
24 _ = interaction; 26 _ = interaction;
25 _ = config; 27 _ = config;
src/gui/picker.zig
Old New
@@ -6,20 +6,11 @@ const client = @import("client");
6 const proto = @import("term").protocol; 6 const proto = @import("term").protocol;
7 const model = @import("workspace.zig"); 7 const model = @import("workspace.zig");
8 const runtime = @import("runtime.zig"); 8 const runtime = @import("runtime.zig");
9 const popover = @import("popover.zig");
9 const discovery = client.discovery; 10 const discovery = client.discovery;
10 pub const Level = enum { hosts, sessions, host_name, session_name, busy }; 11 pub const Level = enum { hosts, sessions, host_name, session_name, busy };
11 pub const Key = enum { up, down, enter, escape, backspace }; 12 pub const Key = enum { up, down, enter, escape, backspace };
12 const Host = struct { label: []const u8, target: ?client.Target, reason: []const u8 = "" }; 13 const Host = struct { label: []const u8, target: ?client.Target, reason: []const u8 = "" };
13 pub const View = struct {
14 rect: model.Rect,
15 first: usize,
16 shown: usize,
17 row_height: u16,
18 pub fn rowRect(self: View, index: usize) model.Rect {
19 if (index < self.first or index >= self.first + self.shown) return .{};
20 return model.Rect.intersect(.{ .x = self.rect.x, .y = self.rect.y + self.row_height * @as(u32, @intCast(1 + index - self.first)), .w = self.rect.w, .h = self.row_height }, self.rect);
21 }
22 };
23 pub const Picker = struct { 14 pub const Picker = struct {
24 pub const Mode = enum { insert, replace, quick }; 15 pub const Mode = enum { insert, replace, quick };
25 alloc: std.mem.Allocator, 16 alloc: std.mem.Allocator,
@@ -32,15 +23,12 @@ pub const Picker = struct {
32 ticket: discovery.Ticket, 23 ticket: discovery.Ticket,
33 next_generation: *u64, 24 next_generation: *u64,
34 level: Level = .hosts, 25 level: Level = .hosts,
35 selected: usize = 0,
36 host_index: usize = 0, 26 host_index: usize = 0,
37 hosts: std.ArrayList(Host) = .empty, 27 hosts: std.ArrayList(Host) = .empty,
38 sessions: [proto.sessions_max][proto.session_name_max]u8 = undefined, 28 sessions: [proto.sessions_max][proto.session_name_max]u8 = undefined,
39 session_lens: [proto.sessions_max]u8 = @splat(0), 29 session_lens: [proto.sessions_max]u8 = @splat(0),
40 session_count: usize = 0, 30 session_count: usize = 0,
41 input: std.ArrayList(u8) = .empty, 31 presentation: popover.State,
42 notice: [1024]u8 = @splat(0),
43 notice_len: usize = 0,
44 job: ?*discovery.Job = null, 32 job: ?*discovery.Job = null,
45 wake_ctx: ?*anyopaque, 33 wake_ctx: ?*anyopaque,
46 wake: ?*const fn (?*anyopaque, discovery.Ticket) void, 34 wake: ?*const fn (?*anyopaque, discovery.Ticket) void,
@@ -63,8 +51,12 @@ pub const Picker = struct {
63 const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null; 51 const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null;
64 if (mode != .insert and pane == null) return error.MissingPane; 52 if (mode != .insert and pane == null) return error.MissingPane;
65 const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null; 53 const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null;
66 const self = try alloc.create(Picker); 54 var presentation = try popover.State.init(alloc, .{});
67 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = origin, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .mode = mode, .ticket = .{ .generation = next_generation.*, .owner = if (origin) |v| v.pane else 0, .attachment_generation = if (origin) |v| v.generation else 0 }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake }; 55 const self = alloc.create(Picker) catch |err| {
56 presentation.deinit();
57 return err;
58 };
59 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = origin, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .mode = mode, .ticket = .{ .generation = next_generation.*, .owner = if (origin) |v| v.pane else 0, .attachment_generation = if (origin) |v| v.generation else 0 }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake, .presentation = presentation };
68 next_generation.* += 1; 60 next_generation.* += 1;
69 errdefer self.deinit(); 61 errdefer self.deinit();
70 if (mode == .quick) { 62 if (mode == .quick) {
@@ -72,8 +64,7 @@ pub const Picker = struct {
72 // including custom socket, key and SSH arguments. 64 // including custom socket, key and SSH arguments.
73 _ = try self.preflight(); 65 _ = try self.preflight();
74 try self.includeTarget(pane.?.identity.target); 66 try self.includeTarget(pane.?.identity.target);
75 const text_ = std.fmt.bufPrint(&self.notice, "Opening on {s}... (Esc cancels)", .{self.host()}) catch self.notice[0..]; 67 try self.setNoticeFmt("Opening on {s}... (Esc cancels)", .{self.host()});
76 self.notice_len = text_.len;
77 try self.list(); 68 try self.list();
78 return self; 69 return self;
79 } 70 }
@@ -93,6 +84,7 @@ pub const Picker = struct {
93 } 84 }
94 } 85 }
95 if (pane) |p| try self.includeTarget(p.identity.target); 86 if (pane) |p| try self.includeTarget(p.identity.target);
87 try self.show(.hosts);
96 return self; 88 return self;
97 } 89 }
98 pub fn includeTarget(self: *Picker, current: client.Target) !void { 90 pub fn includeTarget(self: *Picker, current: client.Target) !void {
@@ -111,89 +103,47 @@ pub const Picker = struct {
111 break; 103 break;
112 }; 104 };
113 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) }); 105 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) });
106 if (self.level == .hosts) try self.show(.hosts);
114 } 107 }
115 pub fn deinit(self: *Picker) void { 108 pub fn deinit(self: *Picker) void {
116 if (self.job != null) self.cancel(); 109 if (self.job != null) self.cancel();
117 self.input.deinit(self.alloc); 110 self.presentation.deinit();
118 self.arena.deinit(); 111 self.arena.deinit();
119 self.alloc.destroy(self); 112 self.alloc.destroy(self);
120 } 113 }
121 pub fn noticeText(self: *const Picker) []const u8 { 114 pub fn noticeText(self: *const Picker) []const u8 {
122 return self.notice[0..self.notice_len]; 115 return self.presentation.noticeText();
123 } 116 }
124 pub fn host(self: *const Picker) []const u8 { 117 pub fn host(self: *const Picker) []const u8 {
125 return if (self.hosts.items.len > 0) self.hosts.items[self.host_index].label else ""; 118 return if (self.hosts.items.len > 0) self.hosts.items[self.host_index].label else "";
126 } 119 }
127 pub fn title(self: *const Picker) []const u8 {
128 return switch (self.level) {
129 .hosts => "Choose host",
130 .sessions => "Choose session",
131 .host_name => "Add host",
132 .session_name => "New session",
133 .busy => if (self.job != null and self.job.?.opts.operation == .create) "Creating session..." else "Loading sessions...",
134 };
135 }
136 pub fn rowCount(self: *const Picker) usize {
137 return switch (self.level) {
138 .hosts => self.hosts.items.len + 1,
139 .sessions => self.session_count + 1,
140 else => 0,
141 };
142 }
143 pub fn rowLabel(self: *const Picker, index: usize) []const u8 {
144 return switch (self.level) {
145 .hosts => if (index == self.hosts.items.len) "Add host..." else self.hosts.items[index].label,
146 .sessions => if (index == self.session_count) "New session..." else self.sessions[index][0..self.session_lens[index]],
147 else => "",
148 };
149 }
150 pub fn view(self: *const Picker) View {
151 const cw: u32 = self.metrics.cell_w;
152 const ch: u32 = self.metrics.cell_h;
153 const w = @min(self.width, cw * 84);
154 const available = self.height / ch;
155 const shown = @min(self.rowCount(), @min(available -| 4, 40));
156 const h = @min(self.height, (@as(u32, @intCast(shown)) + 4) * ch);
157 const first = if (shown > 0 and self.selected >= shown) self.selected - shown + 1 else 0;
158 return .{ .rect = .{ .x = (self.width - w) / 2, .y = (self.height - h) / 2, .w = w, .h = h }, .first = first, .shown = shown, .row_height = self.metrics.cell_h };
159 }
160 pub fn text(self: *Picker, bytes: []const u8) !void { 120 pub fn text(self: *Picker, bytes: []const u8) !void {
161 if (self.level != .host_name and self.level != .session_name) return; 121 const accepted = self.presentation.append(bytes) catch |err| switch (err) {
162 const limit: usize = if (self.level == .session_name) proto.session_name_max else 1024; 122 error.TextTooLong => {
163 if (self.input.items.len + bytes.len > limit) { 123 self.setNotice("Name is too long");
164 self.setNotice("Name is too long"); 124 return;
165 return; 125 },
166 } 126 else => return err,
167 if (!std.unicode.utf8ValidateSlice(bytes)) return; 127 };
168 for (bytes) |b| if (b < 0x20 or b == 0x7f) return; 128 if (accepted) self.clearNotice();
169 try self.input.appendSlice(self.alloc, bytes);
170 self.notice_len = 0;
171 } 129 }
172 pub fn key(self: *Picker, pressed: Key) !void { 130 pub fn key(self: *Picker, pressed: Key) !void {
173 switch (pressed) { 131 switch (pressed) {
174 .up => if (self.rowCount() > 0) { 132 .up => self.presentation.navigate(false),
175 self.selected -|= 1; 133 .down => self.presentation.navigate(true),
176 }, 134 .backspace => if (self.presentation.backspace()) self.clearNotice(),
177 .down => if (self.rowCount() > 0) {
178 self.selected = @min(self.selected + 1, self.rowCount() - 1);
179 },
180 .backspace => if ((self.level == .host_name or self.level == .session_name) and self.input.items.len > 0) {
181 var n = self.input.items.len - 1;
182 while (n > 0 and self.input.items[n] & 0xc0 == 0x80) n -= 1;
183 self.input.items.len = n;
184 self.notice_len = 0;
185 },
186 .escape => try self.back(), 135 .escape => try self.back(),
187 .enter => try self.choose(), 136 .enter => if (self.presentation.submit()) |value| try self.event(value),
188 } 137 }
189 } 138 }
190 pub fn click(self: *Picker, x: u32, y: u32) !void { 139 pub fn pointerDown(self: *Picker, x: u32, y: u32) void {
191 const v = self.view(); 140 self.presentation.pointerDown(self.layout(), x, y);
192 for (v.first..v.first + v.shown) |i| if (v.rowRect(i).contains(x, y)) { 141 }
193 self.selected = i; 142 pub fn pointerUp(self: *Picker, x: u32, y: u32) !void {
194 try self.choose(); 143 if (self.presentation.pointerUp(self.layout(), x, y)) |value| try self.event(value);
195 return; 144 }
196 }; 145 pub fn cancelPress(self: *Picker) void {
146 self.presentation.cancelPress();
197 } 147 }
198 pub fn setDirection(self: *Picker, direction: model.Direction) void { 148 pub fn setDirection(self: *Picker, direction: model.Direction) void {
199 if (self.mode != .insert or (self.level != .hosts and self.level != .sessions) or !self.validOrigin()) return; 149 if (self.mode != .insert or (self.level != .hosts and self.level != .sessions) or !self.validOrigin()) return;
@@ -203,9 +153,8 @@ pub const Picker = struct {
203 } 153 }
204 } 154 }
205 pub fn cancel(self: *Picker) void { 155 pub fn cancel(self: *Picker) void {
206 self.notice_len = 0; 156 self.clearNotice();
207 self.level = .hosts; 157 self.show(.hosts) catch {};
208 self.selected = self.host_index;
209 self.closed = self.mode == .quick; 158 self.closed = self.mode == .quick;
210 if (self.closed) self.setNotice("Pane opening cancelled"); 159 if (self.closed) self.setNotice("Pane opening cancelled");
211 if (self.job) |job| { 160 if (self.job) |job| {
@@ -214,45 +163,58 @@ pub const Picker = struct {
214 self.ticket.generation = self.next_generation.*; 163 self.ticket.generation = self.next_generation.*;
215 self.next_generation.* += 1; 164 self.next_generation.* += 1;
216 if (result.may_have_created) { 165 if (result.may_have_created) {
217 self.level = .session_name; 166 self.show(.session_name) catch {};
218 self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry."); 167 self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry.");
219 } 168 }
220 } 169 }
221 } 170 }
171 /// Pointer dismissal owns its complete gesture and tears down the modal
172 /// after cancelling work. Escape retains the established back/refresh
173 /// screen when an outcome is uncertain.
174 fn dismiss(self: *Picker) void {
175 var previous: [1024]u8 = undefined;
176 const old = self.noticeText();
177 const len = @min(old.len, previous.len);
178 @memcpy(previous[0..len], old[0..len]);
179 self.cancel();
180 if (self.noticeText().len == 0 and len != 0) self.setNotice(previous[0..len]);
181 self.closed = true;
182 }
222 fn back(self: *Picker) !void { 183 fn back(self: *Picker) !void {
223 if (self.mode == .quick) return self.cancel(); 184 if (self.mode == .quick) return self.cancel();
224 self.notice_len = 0; 185 self.clearNotice();
225 switch (self.level) { 186 switch (self.level) {
226 .hosts => self.closed = true, 187 .hosts => self.closed = true,
227 .sessions, .host_name => { 188 .sessions, .host_name => {
228 self.level = .hosts; 189 try self.show(.hosts);
229 self.selected = self.host_index;
230 }, 190 },
231 .session_name => try self.list(), 191 .session_name => try self.list(),
232 .busy => self.cancel(), 192 .busy => self.cancel(),
233 } 193 }
234 } 194 }
235 fn choose(self: *Picker) !void { 195 fn choose(self: *Picker) !void {
236 self.notice_len = 0; 196 self.clearNotice();
237 switch (self.level) { 197 switch (self.level) {
238 .hosts => { 198 .hosts => {
239 if (self.selected == self.hosts.items.len) { 199 const selected = self.presentation.selected orelse return;
240 self.level = .host_name; 200 if (selected == self.hosts.items.len) {
241 self.input.clearRetainingCapacity(); 201 try self.show(.host_name);
202 self.presentation.input.clearRetainingCapacity();
242 } else { 203 } else {
243 self.host_index = self.selected; 204 self.host_index = selected;
244 try self.list(); 205 try self.list();
245 } 206 }
246 }, 207 },
247 .sessions => { 208 .sessions => {
248 if (self.selected == self.session_count) { 209 const selected = self.presentation.selected orelse return;
249 self.level = .session_name; 210 if (selected == self.session_count) {
250 self.input.clearRetainingCapacity(); 211 try self.show(.session_name);
251 } else try self.insert(self.rowLabel(self.selected), false); 212 self.presentation.input.clearRetainingCapacity();
213 } else try self.insert(self.sessions[selected][0..self.session_lens[selected]], false);
252 }, 214 },
253 .host_name => { 215 .host_name => {
254 const a = self.arena.allocator(); 216 const a = self.arena.allocator();
255 const spelling = self.input.items; 217 const spelling = self.presentation.input.items;
256 const spec = client.hosts.parse(spelling) catch |err| { 218 const spec = client.hosts.parse(spelling) catch |err| {
257 self.setNotice(client.hosts.reason(err)); 219 self.setNotice(client.hosts.reason(err));
258 return; 220 return;
@@ -283,7 +245,7 @@ pub const Picker = struct {
283 } 245 }
284 } 246 }
285 fn create(self: *Picker) !void { 247 fn create(self: *Picker) !void {
286 const name = self.input.items; 248 const name = self.presentation.input.items;
287 if (!proto.validSessionName(name)) { 249 if (!proto.validSessionName(name)) {
288 self.setNotice("Choose a valid session name (no spaces)"); 250 self.setNotice("Choose a valid session name (no spaces)");
289 return; 251 return;
@@ -305,9 +267,9 @@ pub const Picker = struct {
305 fn preflight(self: *Picker) !model.Placement { 267 fn preflight(self: *Picker) !model.Placement {
306 if (!self.validOrigin()) return error.OriginChanged; 268 if (!self.validOrigin()) return error.OriginChanged;
307 if (self.mode == .replace) { 269 if (self.mode == .replace) {
308 const layout = self.rt.workspace.layout(self.width, self.height, self.metrics); 270 const workspace_layout = self.rt.workspace.layout(self.width, self.height, self.metrics);
309 if (!layout.fits) return error.TooSmall; 271 if (!workspace_layout.fits) return error.TooSmall;
310 return layout.get(self.origin.?.pane) orelse error.MissingPane; 272 return workspace_layout.get(self.origin.?.pane) orelse error.MissingPane;
311 } 273 }
312 return self.rt.workspace.preview(self.width, self.height, self.metrics); 274 return self.rt.workspace.preview(self.width, self.height, self.metrics);
313 } 275 }
@@ -321,8 +283,8 @@ pub const Picker = struct {
321 return; 283 return;
322 }; 284 };
323 if (self.mode == .replace) { 285 if (self.mode == .replace) {
324 const layout = self.rt.workspace.layout(self.width, self.height, self.metrics); 286 const workspace_layout = self.rt.workspace.layout(self.width, self.height, self.metrics);
325 self.rt.replace(self.origin.?.pane, target, name, layout.get(self.origin.?.pane).?, false) catch |err| { 287 self.rt.replace(self.origin.?.pane, target, name, workspace_layout.get(self.origin.?.pane).?, false) catch |err| {
326 self.insertionFailed(err, created); 288 self.insertionFailed(err, created);
327 return; 289 return;
328 }; 290 };
@@ -346,11 +308,10 @@ pub const Picker = struct {
346 } 308 }
347 fn list(self: *Picker) !void { 309 fn list(self: *Picker) !void {
348 if (self.hosts.items[self.host_index].target == null) { 310 if (self.hosts.items[self.host_index].target == null) {
349 self.level = .hosts; 311 try self.show(.hosts);
350 self.setNotice(self.hosts.items[self.host_index].reason); 312 self.setNotice(self.hosts.items[self.host_index].reason);
351 return; 313 return;
352 } 314 }
353 self.selected = 0;
354 try self.start(.list); 315 try self.start(.list);
355 } 316 }
356 fn start(self: *Picker, operation: discovery.Operation) !void { 317 fn start(self: *Picker, operation: discovery.Operation) !void {
@@ -362,7 +323,7 @@ pub const Picker = struct {
362 self.closed = self.mode == .quick; 323 self.closed = self.mode == .quick;
363 return; 324 return;
364 }; 325 };
365 self.level = .busy; 326 try self.show(.busy);
366 } 327 }
367 pub fn poll(self: *Picker) !bool { 328 pub fn poll(self: *Picker) !bool {
368 const job = self.job orelse return false; 329 const job = self.job orelse return false;
@@ -380,7 +341,7 @@ pub const Picker = struct {
380 } 341 }
381 fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void { 342 fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void {
382 if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) { 343 if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) {
383 self.level = .hosts; 344 try self.show(.hosts);
384 if (result.phase == .created) self.outcomeNotice(true, "Destination changed. Refresh sessions to choose it.") else if (result.may_have_created) self.outcomeNotice(false, "Destination changed. Refresh sessions before retrying.") else self.setNotice("The pane for this request changed"); 345 if (result.phase == .created) self.outcomeNotice(true, "Destination changed. Refresh sessions to choose it.") else if (result.may_have_created) self.outcomeNotice(false, "Destination changed. Refresh sessions before retrying.") else self.setNotice("The pane for this request changed");
385 self.closed = self.mode == .quick; 346 self.closed = self.mode == .quick;
386 return; 347 return;
@@ -389,8 +350,8 @@ pub const Picker = struct {
389 .sessions => { 350 .sessions => {
390 if (self.mode == .quick) { 351 if (self.mode == .quick) {
391 var buf: [proto.session_name_max]u8 = undefined; 352 var buf: [proto.session_name_max]u8 = undefined;
392 self.input.clearRetainingCapacity(); 353 self.presentation.input.clearRetainingCapacity();
393 try self.input.appendSlice(self.alloc, client.nextFreeName(&buf, result.text())); 354 try self.presentation.input.appendSlice(self.alloc, client.nextFreeName(&buf, result.text()));
394 try self.create(); 355 try self.create();
395 return; 356 return;
396 } 357 }
@@ -402,12 +363,11 @@ pub const Picker = struct {
402 self.session_lens[self.session_count] = @intCast(name.len); 363 self.session_lens[self.session_count] = @intCast(name.len);
403 self.session_count += 1; 364 self.session_count += 1;
404 } 365 }
405 self.level = .sessions; 366 try self.show(.sessions);
406 self.selected = 0;
407 }, 367 },
408 .created => { 368 .created => {
409 self.level = .session_name; 369 try self.show(.session_name);
410 try self.insert(self.input.items, true); 370 try self.insert(self.presentation.input.items, true);
411 }, 371 },
412 .exists => { 372 .exists => {
413 // A definite collision is safe to retry. A lost reply is not: 373 // A definite collision is safe to retry. A lost reply is not:
@@ -416,14 +376,14 @@ pub const Picker = struct {
416 self.collisions += 1; 376 self.collisions += 1;
417 try self.list(); 377 try self.list();
418 } else { 378 } else {
419 self.level = .session_name; 379 try self.show(.session_name);
420 self.closed = self.mode == .quick; 380 self.closed = self.mode == .quick;
421 self.setNotice(if (self.mode == .quick) "Session names keep changing; try again" else result.reasonText()); 381 self.setNotice(if (self.mode == .quick) "Session names keep changing; try again" else result.reasonText());
422 } 382 }
423 }, 383 },
424 else => { 384 else => {
425 self.level = if (creating) .session_name else .sessions;
426 if (!creating) self.session_count = 0; 385 if (!creating) self.session_count = 0;
386 try self.show(if (creating) .session_name else .sessions);
427 if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText()); 387 if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText());
428 self.closed = self.mode == .quick; 388 self.closed = self.mode == .quick;
429 }, 389 },
@@ -432,15 +392,65 @@ pub const Picker = struct {
432 } 392 }
433 fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void { 393 fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void {
434 const outcome = if (created) "Session created" else "Creation outcome unknown for"; 394 const outcome = if (created) "Session created" else "Creation outcome unknown for";
435 const message = if (self.mode == .quick) 395 var message: [1024]u8 = undefined;
436 std.fmt.bufPrint(&self.notice, "{s} '{s}' on {s}. Prefix Enter to check sessions.", .{ outcome, self.input.items, self.host() }) catch self.notice[0..] 396 const message_text = if (self.mode == .quick)
397 std.fmt.bufPrint(&message, "{s} '{s}' on {s}. Prefix Enter to check sessions.", .{ outcome, self.presentation.input.items, self.host() }) catch "Creation outcome is unknown"
437 else 398 else
438 std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ outcome, self.input.items, suffix }) catch self.notice[0..]; 399 std.fmt.bufPrint(&message, "{s} '{s}'. {s}", .{ outcome, self.presentation.input.items, suffix }) catch "Creation outcome is unknown";
439 self.notice_len = message.len; 400 self.setNotice(message_text);
401 }
402 pub fn layout(self: *Picker) popover.Layout {
403 return self.presentation.layout(self.width, self.height, .{ .cell_w = self.metrics.cell_w, .cell_h = self.metrics.cell_h });
404 }
405 fn event(self: *Picker, value: popover.Event) !void {
406 switch (value) {
407 .select => |index| {
408 self.presentation.selected = index;
409 try self.choose();
410 },
411 .submit => try self.choose(),
412 .dismiss => self.dismiss(),
413 }
414 }
415 /// Picker screens remain workflow state, but each screen has exactly one
416 /// owned presentation. Replacement completes before the screen changes.
417 pub fn show(self: *Picker, next: Level) !void {
418 var rows: std.ArrayListUnmanaged(popover.Row) = .empty;
419 defer rows.deinit(self.alloc);
420 switch (next) {
421 .hosts => {
422 for (self.hosts.items) |entry| try rows.append(self.alloc, .{ .label = entry.label });
423 try rows.append(self.alloc, .{ .label = "Add host..." });
424 },
425 .sessions => {
426 for (0..self.session_count) |i| try rows.append(self.alloc, .{ .label = self.sessions[i][0..self.session_lens[i]] });
427 try rows.append(self.alloc, .{ .label = "New session..." });
428 },
429 else => {},
430 }
431 const title = switch (next) {
432 .hosts => "Choose host",
433 .sessions => "Choose session",
434 .host_name => "Add host",
435 .session_name => "New session",
436 .busy => if (self.job != null and self.job.?.opts.operation == .create) "Creating session..." else "Loading sessions...",
437 };
438 const editing = next == .host_name or next == .session_name;
439 const hint = if (editing) "Enter confirms | Esc goes back" else if (next == .busy) "Esc cancels" else if (self.mode == .insert) "j/k choose | Enter selects | v below, b beside | Esc back" else "Up/Down or j/k choose | Enter selects | Esc goes back";
440 try self.presentation.replace(.{ .title = title, .context = if (next != .hosts and next != .host_name) self.host() else "", .notice = self.noticeText(), .hint = hint, .rows = rows.items, .editor = if (editing) .{ .limit = if (next == .session_name) proto.session_name_max else 1024 } else null });
441 self.level = next;
442 if (next == .hosts and self.host_index < self.hosts.items.len) self.presentation.selected = self.host_index;
443 }
444 fn clearNotice(self: *Picker) void {
445 if (self.noticeText().len == 0) return;
446 self.presentation.setNotice("");
447 }
448 fn setNoticeFmt(self: *Picker, comptime format: []const u8, args: anytype) !void {
449 var bytes: [1024]u8 = undefined;
450 self.setNotice(try std.fmt.bufPrint(&bytes, format, args));
440 } 451 }
441 fn setNotice(self: *Picker, text_: []const u8) void { 452 fn setNotice(self: *Picker, text_: []const u8) void {
442 self.notice_len = @min(text_.len, self.notice.len); 453 self.presentation.setNotice(text_);
443 @memcpy(self.notice[0..self.notice_len], text_[0..self.notice_len]);
444 } 454 }
445 }; 455 };
446 456
@@ -452,15 +462,35 @@ test "picker validates fit before create and preserves remote outcome after orig
452 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics); 462 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
453 rt.workspace.arm(.beside); 463 rt.workspace.arm(.beside);
454 var next: u64 = 2; 464 var next: u64 = 2;
455 var picker: 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 = 1, .height = 1, .metrics = metrics, .wake = null, .wake_ctx = null }; 465 var picker: 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 = 1, .height = 1, .metrics = metrics, .wake = null, .wake_ctx = null, .presentation = try popover.State.init(a, .{}) };
456 defer picker.arena.deinit(); 466 defer picker.arena.deinit();
457 defer picker.input.deinit(a); 467 defer picker.presentation.deinit();
458 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); 468 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
459 picker.level = .session_name; 469 try picker.includeTarget(.{ .via = "local" });
470 try std.testing.expectEqualStrings("Current connection", picker.presentation.view().rows[1].label);
471 try picker.show(.session_name);
460 try picker.text("new-shell"); 472 try picker.text("new-shell");
461 try picker.key(.enter); 473 try picker.key(.enter);
462 try std.testing.expect(picker.job == null); 474 try std.testing.expect(picker.job == null);
463 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "TooSmall") != null); 475 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "TooSmall") != null);
476 try picker.text("\x01");
477 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "TooSmall") != null);
478 picker.dismiss();
479 try std.testing.expect(picker.closed and std.mem.indexOf(u8, picker.noticeText(), "TooSmall") != null);
480 picker.closed = false;
481 try picker.show(.session_name);
482 try picker.show(.busy);
483 try picker.key(.backspace);
484 try std.testing.expectEqualStrings("new-shell", picker.presentation.input.items);
485 try picker.show(.session_name);
486 picker.session_count = 1;
487 @memcpy(picker.sessions[0][0..3], "old");
488 picker.session_lens[0] = 3;
489 try picker.show(.sessions);
490 try picker.applyResult(.{ .ticket = picker.ticket, .phase = .failed }, false);
491 try std.testing.expectEqual(@as(usize, 0), picker.session_count);
492 try std.testing.expectEqualStrings("New session...", picker.presentation.view().rows[0].label);
493 try picker.show(.session_name);
464 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len); 494 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
465 495
466 picker.width = 800; 496 picker.width = 800;
@@ -497,9 +527,9 @@ test "quick creation retries collisions but retains unknown outcomes and its ori
497 _ = rt.workspace.focus(id); 527 _ = rt.workspace.focus(id);
498 rt.workspace.arm(.stacked); 528 rt.workspace.arm(.stacked);
499 var next: u64 = 2; 529 var next: u64 = 2;
500 var opening: 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, .mode = .quick, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 1200, .height = 900, .metrics = metrics, .wake = null, .wake_ctx = null }; 530 var opening: 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, .mode = .quick, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 1200, .height = 900, .metrics = metrics, .wake = null, .wake_ctx = null, .presentation = try popover.State.init(a, .{}) };
501 defer opening.arena.deinit(); 531 defer opening.arena.deinit();
502 defer opening.input.deinit(a); 532 defer opening.presentation.deinit();
503 defer if (opening.job) |job| job.stop(); 533 defer if (opening.job) |job| job.stop();
504 try opening.includeTarget(rt.workspace.pane(id).?.identity.target); 534 try opening.includeTarget(rt.workspace.pane(id).?.identity.target);
505 var result: discovery.Result = .{ .ticket = opening.ticket, .phase = .sessions }; 535 var result: discovery.Result = .{ .ticket = opening.ticket, .phase = .sessions };
src/gui/popover.zig
Old New
@@ -0,0 +1,265 @@
1 //! Small reusable modal presentation state. Workflows own the meanings of
2 //! rows and events; this module owns the displayed copy, editor, selection,
3 //! scrolling, and the geometry shared by painting and hit testing.
4 const std = @import("std");
5
6 pub const Rect = struct {
7 x: u32 = 0,
8 y: u32 = 0,
9 w: u32 = 0,
10 h: u32 = 0,
11 pub fn contains(self: Rect, x: u32, y: u32) bool {
12 return x >= self.x and y >= self.y and x - self.x < self.w and y - self.y < self.h;
13 }
14 pub fn intersect(a: Rect, b: Rect) Rect {
15 const left = @max(a.x, b.x);
16 const top = @max(a.y, b.y);
17 const right = @min(a.x +| a.w, b.x +| b.w);
18 const bottom = @min(a.y +| a.h, b.y +| b.h);
19 return .{ .x = left, .y = top, .w = right -| left, .h = bottom -| top };
20 }
21 };
22 pub const Row = struct { label: []const u8, enabled: bool = true };
23 pub const Editor = struct { limit: usize };
24 pub const Spec = struct {
25 title: []const u8 = "",
26 context: []const u8 = "",
27 notice: []const u8 = "",
28 hint: []const u8 = "",
29 rows: []const Row = &.{},
30 editor: ?Editor = null,
31 };
32 pub const Metrics = struct { cell_w: u16, cell_h: u16, max_rows: usize = 40, width_cells: u32 = 84 };
33 /// `submit` borrows the editor until its next mutation, replacement, or deinit;
34 /// asynchronous workflows must copy it before retaining it.
35 pub const Event = union(enum) { select: usize, submit: []const u8, dismiss };
36 pub const Layout = struct {
37 rect: Rect,
38 first: usize,
39 shown: usize,
40 cell_width: u16,
41 row_height: u16,
42 header_rows: usize,
43 editor: ?Rect,
44 pub fn rowRect(self: Layout, index: usize) Rect {
45 if (index < self.first or index - self.first >= self.shown) return .{};
46 return Rect.intersect(.{ .x = self.rect.x, .y = self.rect.y + self.row_height * @as(u32, @intCast(self.header_rows + index - self.first)), .w = self.rect.w, .h = self.row_height }, self.rect);
47 }
48 };
49 /// Borrowed rendering data, valid until the next State mutation or deinit.
50 pub const Presentation = struct {
51 title: []const u8,
52 context: []const u8,
53 notice: []const u8,
54 hint: []const u8,
55 rows: []const Row,
56 editor: ?[]const u8,
57 selected: ?usize,
58 };
59
60 const Owned = struct {
61 title: []u8,
62 context: []u8,
63 hint: []u8,
64 rows: std.ArrayListUnmanaged(Row) = .empty,
65 editor: ?Editor,
66 fn deinit(self: *Owned, alloc: std.mem.Allocator) void {
67 alloc.free(self.title);
68 alloc.free(self.context);
69 alloc.free(self.hint);
70 for (self.rows.items) |row| alloc.free(@constCast(row.label));
71 self.rows.deinit(alloc);
72 self.* = undefined;
73 }
74 fn init(alloc: std.mem.Allocator, spec: Spec) !Owned {
75 const title = try alloc.dupe(u8, spec.title);
76 errdefer alloc.free(title);
77 const context = try alloc.dupe(u8, spec.context);
78 errdefer alloc.free(context);
79 const hint = try alloc.dupe(u8, spec.hint);
80 errdefer alloc.free(hint);
81 var result: Owned = .{ .title = title, .context = context, .hint = hint, .editor = spec.editor };
82 errdefer {
83 for (result.rows.items) |row| alloc.free(@constCast(row.label));
84 result.rows.deinit(alloc);
85 }
86 for (spec.rows) |row| {
87 if (!std.unicode.utf8ValidateSlice(row.label)) return error.InvalidUtf8;
88 const label = try alloc.dupe(u8, row.label);
89 errdefer alloc.free(label);
90 try result.rows.append(alloc, .{ .label = label, .enabled = row.enabled });
91 }
92 return result;
93 }
94 };
95
96 pub const State = struct {
97 alloc: std.mem.Allocator,
98 owned: Owned,
99 notice: [1024]u8 = @splat(0),
100 notice_len: usize = 0,
101 input: std.ArrayListUnmanaged(u8) = .empty,
102 selected: ?usize = null,
103 scroll: usize = 0,
104 press: ?struct { row: ?usize, outside: bool } = null,
105 pub fn init(alloc: std.mem.Allocator, spec: Spec) !State {
106 var result: State = .{ .alloc = alloc, .owned = try Owned.init(alloc, spec) };
107 result.setNotice(spec.notice);
108 result.selectFirst();
109 return result;
110 }
111 pub fn deinit(self: *State) void {
112 self.input.deinit(self.alloc);
113 self.owned.deinit(self.alloc);
114 self.* = undefined;
115 }
116 pub fn view(self: *const State) Presentation {
117 return .{ .title = self.owned.title, .context = self.owned.context, .notice = self.noticeText(), .hint = self.owned.hint, .rows = self.owned.rows.items, .editor = if (self.owned.editor != null) self.input.items else null, .selected = self.selected };
118 }
119 /// Replacement allocates the full new presentation first, so failure leaves
120 /// both the visible screen and editor untouched.
121 pub fn replace(self: *State, spec: Spec) !void {
122 var notice: [1024]u8 = undefined;
123 const notice_len = @min(spec.notice.len, notice.len);
124 std.mem.copyForwards(u8, notice[0..notice_len], spec.notice[0..notice_len]);
125 var replacement = try Owned.init(self.alloc, spec);
126 errdefer replacement.deinit(self.alloc);
127 self.owned.deinit(self.alloc);
128 self.owned = replacement;
129 self.setNotice(notice[0..notice_len]);
130 self.scroll = 0;
131 self.press = null;
132 self.selectFirst();
133 }
134 pub fn layout(self: *State, width: u32, height: u32, metrics: Metrics) Layout {
135 if (metrics.cell_w == 0 or metrics.cell_h == 0) return .{ .rect = .{}, .first = 0, .shown = 0, .cell_width = 0, .row_height = 0, .header_rows = 0, .editor = null };
136 const available = height / metrics.cell_h;
137 // The legacy picker has title, variable body, context, notice and
138 // hint. Editors occupy the body row; all five regions share this one
139 // centered geometry contract.
140 const shown = @min(self.owned.rows.items.len, @min(metrics.max_rows, available -| 4));
141 const rows = 4 + shown;
142 const w = @min(width, @as(u32, metrics.cell_w) * metrics.width_cells);
143 const h = @min(height, @as(u32, @intCast(rows)) * metrics.cell_h);
144 if (self.selected) |selected| if (shown != 0) {
145 if (selected < self.scroll) self.scroll = selected else if (selected - self.scroll >= shown) self.scroll = selected - shown + 1;
146 };
147 self.scroll = @min(self.scroll, self.owned.rows.items.len -| shown);
148 const rect: Rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h };
149 return .{ .rect = rect, .first = self.scroll, .shown = shown, .cell_width = metrics.cell_w, .row_height = metrics.cell_h, .header_rows = 1, .editor = if (self.owned.editor != null) Rect.intersect(.{ .x = rect.x, .y = rect.y + metrics.cell_h, .w = rect.w, .h = metrics.cell_h }, rect) else null };
150 }
151 pub fn navigate(self: *State, next: bool) void {
152 const current = self.selected orelse return;
153 var at: isize = @intCast(current);
154 while (true) {
155 at += if (next) 1 else -1;
156 if (at < 0 or at >= @as(isize, @intCast(self.owned.rows.items.len))) return;
157 if (self.owned.rows.items[@intCast(at)].enabled) {
158 self.selected = @intCast(at);
159 return;
160 }
161 }
162 }
163 pub fn activate(self: *State) ?Event {
164 return if (self.selected) |index| .{ .select = index } else null;
165 }
166 pub fn submit(self: *State) ?Event {
167 return if (self.owned.editor != null) .{ .submit = self.input.items } else self.activate();
168 }
169 pub fn append(self: *State, bytes: []const u8) !bool {
170 const editor = self.owned.editor orelse return false;
171 if (!std.unicode.utf8ValidateSlice(bytes)) return false;
172 for (bytes) |byte| if (byte < 0x20 or byte == 0x7f) return false;
173 if (self.input.items.len + bytes.len > editor.limit) return error.TextTooLong;
174 try self.input.appendSlice(self.alloc, bytes);
175 return true;
176 }
177 pub fn backspace(self: *State) bool {
178 if (self.owned.editor == null or self.input.items.len == 0) return false;
179 var n = self.input.items.len - 1;
180 while (n > 0 and self.input.items[n] & 0xc0 == 0x80) n -= 1;
181 self.input.items.len = n;
182 return true;
183 }
184 pub fn pointerDown(self: *State, layout_: Layout, x: u32, y: u32) void {
185 self.press = .{ .row = self.itemAt(layout_, x, y), .outside = !layout_.rect.contains(x, y) };
186 }
187 pub fn pointerUp(self: *State, layout_: Layout, x: u32, y: u32) ?Event {
188 const press = self.press orelse return null;
189 self.press = null;
190 if (press.outside) return if (!layout_.rect.contains(x, y)) .dismiss else null;
191 const index = press.row orelse return null;
192 if (self.itemAt(layout_, x, y) == index and self.owned.rows.items[index].enabled) {
193 self.selected = index;
194 return .{ .select = index };
195 }
196 return null;
197 }
198 pub fn cancelPress(self: *State) void {
199 self.press = null;
200 }
201 pub fn pressing(self: *const State) bool {
202 return self.press != null;
203 }
204 /// Unlike screen replacement, a notice never changes selection, scrolling,
205 /// or the editor. It is fixed storage so outcome reporting cannot vanish
206 /// while handling an allocator failure or cancellation.
207 pub fn setNotice(self: *State, text: []const u8) void {
208 self.notice_len = @min(text.len, self.notice.len);
209 std.mem.copyForwards(u8, self.notice[0..self.notice_len], text[0..self.notice_len]);
210 }
211 pub fn noticeText(self: *const State) []const u8 {
212 return self.notice[0..self.notice_len];
213 }
214 fn itemAt(_: *const State, layout_: Layout, x: u32, y: u32) ?usize {
215 for (layout_.first..layout_.first + layout_.shown) |index| if (layout_.rowRect(index).contains(x, y)) return index;
216 return null;
217 }
218 fn selectFirst(self: *State) void {
219 self.selected = null;
220 for (self.owned.rows.items, 0..) |row, i| if (row.enabled) {
221 self.selected = i;
222 return;
223 };
224 }
225 };
226
227 test "replacement is transactional and layout shares scroll geometry with hit testing" {
228 var fail_index: usize = 0;
229 while (true) : (fail_index += 1) {
230 var state = try State.init(std.testing.allocator, .{ .title = "old", .rows = &.{ .{ .label = "one" }, .{ .label = "two" } } });
231 defer state.deinit();
232 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index });
233 state.alloc = failing.allocator();
234 if (state.replace(.{ .title = "new", .context = "replacement", .notice = "notice", .hint = "hint", .rows = &.{.{ .label = "x" }} })) |_| {
235 state.alloc = std.testing.allocator;
236 try std.testing.expectEqualStrings("new", state.owned.title);
237 break;
238 } else |err| {
239 state.alloc = std.testing.allocator;
240 try std.testing.expectEqual(error.OutOfMemory, err);
241 try std.testing.expectEqualStrings("old", state.owned.title);
242 }
243 }
244 var state = try State.init(std.testing.allocator, .{ .title = "old", .rows = &.{ .{ .label = "one" }, .{ .label = "two" } } });
245 defer state.deinit();
246 const layout_ = state.layout(80, 60, .{ .cell_w = 8, .cell_h = 10, .max_rows = 2 });
247 const row = layout_.rowRect(0);
248 state.pointerDown(layout_, row.x, row.y);
249 try std.testing.expectEqual(@as(?usize, 0), switch (state.pointerUp(layout_, row.x, row.y).?) {
250 .select => |index| index,
251 else => null,
252 });
253 try state.replace(.{ .title = "new", .notice = state.view().title });
254 try std.testing.expectEqualStrings("old", state.noticeText());
255 }
256
257 test "busy presentation retains but cannot edit the submitted name" {
258 var state = try State.init(std.testing.allocator, .{ .editor = .{ .limit = 20 } });
259 defer state.deinit();
260 try std.testing.expect(try state.append("shell"));
261 try state.replace(.{ .title = "Creating" });
262 try std.testing.expect(!state.backspace());
263 try std.testing.expect(!(try state.append("x")));
264 try std.testing.expectEqualStrings("shell", state.input.items);
265 }
test/native_picker.py
Old New
@@ -66,6 +66,23 @@ def region(capture, rect):
66 for row in range(y, bottom)) 66 for row in range(y, bottom))
67 67
68 68
69 def outside_picker_point(rig, state, avoid=None, strict_avoid=False):
70 """Pick a covered pane header point that is demonstrably outside the popup."""
71 rect = state["picker"]["rect"]
72 for prefer_unfocused in (True, False):
73 if not prefer_unfocused and strict_avoid:
74 break
75 for pane in state["panes"]:
76 if prefer_unfocused and pane["id"] == avoid:
77 continue
78 header = pane["header"]
79 x, y = header["x"] + header["w"] / 2, header["y"] + header["h"] / 2
80 if not (rect["x"] <= x < rect["x"] + rect["w"] and
81 rect["y"] <= y < rect["y"] + rect["h"]):
82 return rig.point(state, x, y)
83 raise AssertionError("picker covered every pane header; fixture has no outside point")
84
85
69 class SlowHost: 86 class SlowHost:
70 """A real socket peer, with optional delayed list and ignored create requests.""" 87 """A real socket peer, with optional delayed list and ignored create requests."""
71 def __init__(self, root, label, delay=0): 88 def __init__(self, root, label, delay=0):
@@ -172,6 +189,40 @@ def workflow(rig, refs):
172 rig.ok("window becoming too small during name entry refuses split before creating a remote shell") 189 rig.ok("window becoming too small during name entry refuses split before creating a remote shell")
173 190
174 191
192 def pointer_gestures(rig, refs):
193 """The shared popup presents picker rows without leaking covered pane input."""
194 initial = rig.state()
195 initial_focus, initial_pending = initial["focus"], initial["pending"]
196 origin, neighbour, _ = list(refs)
197 second, _ = refs[neighbour]
198 target = rig.targets.get(second, "--sock " + second)
199 rig.focus(origin)
200 rig.open_picker("beside")
201 state = rig.picker("hosts")
202 row = next(row for row in state["picker"]["rows"] if row["label"] == target)
203 point = rig.point(state, row["rect"]["x"] + row["rect"]["w"] / 2,
204 row["rect"]["y"] + row["rect"]["h"] / 2)
205 rig.send("mousedown:" + point)
206 held = rig.picker("hosts")
207 require(held["focus"] == origin, "picker row press focused a covered pane before release")
208 rig.send("mouseup:" + point)
209 state = rig.picker("sessions")
210 require(state["focus"] == origin, "picker row release focused a covered pane")
211 outside = outside_picker_point(rig, state, avoid=origin, strict_avoid=True)
212 rig.send("mousedown:" + outside, "mouseup:" + outside)
213 closed = rig.wait_state(lambda s: not s.get("picker"))
214 require(closed["focus"] == origin and len(closed["panes"]) == 3,
215 "outside picker gesture leaked focus or changed the plural workspace")
216 rig.key("escape")
217 rig.wait_state(lambda s: s["pending"] is None)
218 if initial_focus is not None:
219 rig.focus(initial_focus)
220 restored = rig.wait_state(lambda s: s["focus"] == initial_focus and s["pending"] == initial_pending)
221 require(len(restored["panes"]) == len(initial["panes"]),
222 "picker pointer fixture did not restore its workspace setup")
223 rig.ok("picker row activation waits for release; outside dismissal stays owned over plural panes")
224
225
175 def races(rig, refs): 226 def races(rig, refs):
176 first, left_session = refs[next(iter(refs))] 227 first, left_session = refs[next(iter(refs))]
177 second, right_session = refs[list(refs)[1]] 228 second, right_session = refs[list(refs)[1]]
@@ -180,7 +231,9 @@ def races(rig, refs):
180 rig.host(first_target, "beside") 231 rig.host(first_target, "beside")
181 rig.choose("New session...") 232 rig.choose("New session...")
182 rig.picker("session_name") 233 rig.picker("session_name")
183 rig.send("text:raced-name") 234 rig.send("key:r")
235 rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "r")
236 rig.send("text:aced-name")
184 create(first, "raced-name") 237 create(first, "raced-name")
185 old_size = rig.status(first, "raced-name") 238 old_size = rig.status(first, "raced-name")
186 rig.key("enter") 239 rig.key("enter")
@@ -272,16 +325,49 @@ def slow_and_legacy(rig):
272 rig.ok("slow discovery cancels promptly; stale replies cannot replace a new host or block terminal painting") 325 rig.ok("slow discovery cancels promptly; stale replies cannot replace a new host or block terminal painting")
273 326
274 rig.host(legacy_target) 327 rig.host(legacy_target)
275 rig.new_session("never-fallback") 328 rig.choose("New session...")
329 rig.picker("session_name")
330 rig.send("key:r")
331 rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "r")
332 rig.send("text:aced-fallback", "key:enter")
276 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20) 333 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
277 require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane") 334 require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane")
278 require(sum(kind == 0x12 for kind, _ in legacy.requests) == 1, 335 require(sum(kind == 0x12 for kind, _ in legacy.requests) == 1,
279 "creation was not submitted exactly once") 336 "creation was not submitted exactly once")
280 require(not any(kind == 0x01 for kind, _ in legacy.requests), 337 require(not any(kind == 0x01 for kind, _ in legacy.requests),
281 "old daemon was sent an unsafe attach fallback") 338 "old daemon was sent an unsafe attach fallback")
339 state = rig.state()
340 outside = outside_picker_point(rig, state)
341 rig.send("mousedown:" + outside, "mouseup:" + outside)
342 rig.wait_state(lambda s: not s.get("picker") and
343 any(word in s["notice"].lower() for word in ("unknown", "uncertain")))
344 rig.key("escape")
345 rig.wait_state(lambda s: s["pending"] is None)
282 rig.ok("older daemon's unanswered create reports uncertainty without retry or attach fallback") 346 rig.ok("older daemon's unanswered create reports uncertainty without retry or attach fallback")
283 rig.send("key:escape", "key:escape", "key:escape") 347
284 rig.wait_state(lambda s: not s.get("picker")) 348 rig.host(legacy_target)
349 rig.choose("New session...")
350 rig.picker("session_name")
351 rig.send("key:p")
352 rig.wait_state(lambda s: s.get("picker") and s["picker"]["input"] == "p")
353 before_creates = sum(kind == 0x12 for kind, _ in legacy.requests)
354 rig.send("text:ending-dismiss", "key:enter")
355 eventually(lambda: sum(kind == 0x12 for kind, _ in legacy.requests) == before_creates + 1,
356 "pending dismissal create request was not dispatched")
357 state = rig.picker("busy")
358 require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane")
359 require(not any(kind == 0x01 for kind, _ in legacy.requests),
360 "old daemon was sent an unsafe attach fallback")
361 outside = outside_picker_point(rig, state)
362 rig.send("mousedown:" + outside, "mouseup:" + outside)
363 rig.wait_state(lambda s: not s.get("picker") and
364 any(word in s["notice"].lower() for word in ("unknown", "uncertain")),
365 seconds=20)
366 require(sum(kind == 0x12 for kind, _ in legacy.requests) == before_creates + 1,
367 "pending dismissal retried creation")
368 rig.key("escape")
369 rig.wait_state(lambda s: s["pending"] is None)
370 rig.ok("ordinary keydown text enters the editor; outside close retains a pending create outcome")
285 rig.chord("enter") 371 rig.chord("enter")
286 rig.picker("hosts") 372 rig.picker("hosts")
287 before_requests = sum(kind == 0x0c for kind, _ in slow.requests) 373 before_requests = sum(kind == 0x0c for kind, _ in slow.requests)
@@ -325,7 +411,9 @@ def main():
325 from native_lifecycle import LifecycleRig, start_workspace 411 from native_lifecycle import LifecycleRig, start_workspace
326 rig = LifecycleRig(*sys.argv[1:3]) 412 rig = LifecycleRig(*sys.argv[1:3])
327 try: 413 try:
328 races(rig, start_workspace(rig, quic=True)) 414 refs = start_workspace(rig, quic=True)
415 pointer_gestures(rig, refs)
416 races(rig, refs)
329 add_host(rig) 417 add_host(rig)
330 slow_and_legacy(rig) 418 slow_and_legacy(rig)
331 if binary := os.environ.get("MUXG_OLD_DAEMON"): 419 if binary := os.environ.get("MUXG_OLD_DAEMON"):
test/native_selection.py
Old New
@@ -414,9 +414,12 @@ def selection_basics(rig, refs):
414 rig.chord('enter') 414 rig.chord('enter')
415 rig.picker('hosts') 415 rig.picker('hosts')
416 rig.select(target, (0, 1), (4, 1)) 416 rig.select(target, (0, 1), (4, 1))
417 dismissed = rig.wait_state(lambda s: not s.get('picker'))
418 require(dismissed['focus'] == neighbour,
419 'outside picker gesture focused the covered terminal pane')
417 rig.unchanged(baseline) 420 rig.unchanged(baseline)
418 rig.key('escape') 421 rig.key('escape')
419 rig.key('escape') 422 rig.wait_state(lambda s: s['pending'] is None)
420 rig.ok('same-cell tremor, header press and modal pointer input preserve clipboard') 423 rig.ok('same-cell tremor, header press and modal pointer input preserve clipboard')
421 424
422 # Start in one pane, enter its neighbour, then release back over the origin. 425 # Start in one pane, enter its neighbour, then release back over the origin.