a73x

df2dc36a

refactor: extract reusable picker presentation

a73x   2026-09-09 12:48

Commit message
refactor: extract reusable picker presentation

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.