df2dc36a
refactor: extract reusable picker presentation
a73x 2026-09-09 12:48
Commit message
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. |