c89f85dd
feat: open native panes on the focused target
a73x 2026-09-07 13:34
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -149,8 +149,14 @@ Open the saved workspace, or an explicit target in a temporary workspace: | |||
| 149 | ``` | 149 | ``` |
| 150 | 150 | ||
| 151 | Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. | 151 | Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it. |
| 152 | This shows the intended split. Press the prefix again, then **Enter**, to open | 152 | The new pane uses the focused pane's connection and gets the next free numeric |
| 153 | the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host, | 153 | session name automatically. It opens in the background; you can keep typing or |
| 154 | change focus while it connects. Esc cancels a pending opening. With no focused | ||
| 155 | pane, these bindings open the picker. | ||
| 156 | |||
| 157 | Press the prefix, then **Enter**, to choose a different host or an existing or | ||
| 158 | named session. Arrows or **j/k** select a row; Enter chooses it. **v/b** change | ||
| 159 | the split direction in the host and session lists. Select a host, | ||
| 154 | then an existing session or **New session...**, which asks for a name. **Add host...** | 160 | then an existing session or **New session...**, which asks for a name. **Add host...** |
| 155 | accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared | 161 | accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared |
| 156 | host catalogue. You can also click picker rows. The split stays on the pane where | 162 | host catalogue. You can also click picker rows. The split stays on the pane where |
| @@ -159,7 +165,8 @@ armed direction defaults to side by side. | |||
| 159 | 165 | ||
| 160 | Esc in a name editor returns without submitting; Esc in sessions returns to hosts; | 166 | Esc in a name editor returns without submitting; Esc in sessions returns to hosts; |
| 161 | Esc in hosts closes the picker while preserving the pending split. Cancelling | 167 | Esc in hosts closes the picker while preserving the pending split. Cancelling |
| 162 | before submitting a new session creates nothing. A name collision is refused. | 168 | before submitting a new session creates nothing. An explicit name collision is |
| 169 | refused; automatic naming refreshes the list and retries a confirmed collision. | ||
| 163 | If creation was submitted but its reply was lost, the picker reports an unknown | 170 | If creation was submitted but its reply was lost, the picker reports an unknown |
| 164 | outcome; refresh the session list before retrying. Existing-session selection | 171 | outcome; refresh the session list before retrying. Existing-session selection |
| 165 | never recreates a vanished shell. Older daemons can serve existing sessions but | 172 | never recreates a vanished shell. Older daemons can serve existing sessions but |
src/client/discovery.zig
| Old | New | ||
|---|---|---|---|
| @@ -28,6 +28,10 @@ pub const Options = struct { | |||
| 28 | ticket: Ticket, | 28 | ticket: Ticket, |
| 29 | target: client.Target, | 29 | target: client.Target, |
| 30 | operation: Operation = .list, | 30 | operation: Operation = .list, |
| 31 | /// Background catalogues use a noninteractive SSH recipe. An explicit | ||
| 32 | /// opening on an existing target must retain that target's custom argv. | ||
| 33 | /// Listings remain read-only in either case: inherited startup is cleared. | ||
| 34 | use_poll_recipe: bool = true, | ||
| 31 | timeout_ms: u32 = 15000, | 35 | timeout_ms: u32 = 15000, |
| 32 | wake_ctx: ?*anyopaque = null, | 36 | wake_ctx: ?*anyopaque = null, |
| 33 | wake: ?*const fn (?*anyopaque, Ticket) void = null, | 37 | wake: ?*const fn (?*anyopaque, Ticket) void = null, |
| @@ -46,8 +50,12 @@ pub const Job = struct { | |||
| 46 | var arena = std.heap.ArenaAllocator.init(alloc); | 50 | var arena = std.heap.ArenaAllocator.init(alloc); |
| 47 | errdefer arena.deinit(); | 51 | errdefer arena.deinit(); |
| 48 | var opts = options; | 52 | var opts = options; |
| 49 | const source = if (opts.operation == .list) try client.pollTargetFor(arena.allocator(), opts.target) else opts.target; | 53 | const source = if (opts.operation == .list and opts.use_poll_recipe) try client.pollTargetFor(arena.allocator(), opts.target) else opts.target; |
| 50 | opts.target = try cloneTarget(arena.allocator(), source); | 54 | opts.target = try cloneTarget(arena.allocator(), source); |
| 55 | if (opts.operation == .list and opts.target == .hand) { | ||
| 56 | opts.target.hand.asked = false; | ||
| 57 | opts.target.hand.narrate = false; | ||
| 58 | } | ||
| 51 | if (opts.operation == .create) { | 59 | if (opts.operation == .create) { |
| 52 | const req = &opts.operation.create; | 60 | const req = &opts.operation.create; |
| 53 | if (!proto.validSessionName(req.name) or req.cols < 2 or req.rows == 0 or req.cols > proto.max_cols) return error.InvalidCreate; | 61 | if (!proto.validSessionName(req.name) or req.cols < 2 or req.rows == 0 or req.cols > proto.max_cols) return error.InvalidCreate; |
| @@ -293,3 +301,22 @@ test "picker create uses explicit operation and reports ambiguous cancellation a | |||
| 293 | try std.testing.expectEqual(cancelled, result.may_have_created); | 301 | try std.testing.expectEqual(cancelled, result.may_have_created); |
| 294 | } | 302 | } |
| 295 | } | 303 | } |
| 304 | |||
| 305 | test "same-target listing retains custom SSH argv without inheriting daemon startup" { | ||
| 306 | const job = try Job.start(std.testing.allocator, .{ | ||
| 307 | .ticket = .{ .generation = 1, .owner = 1, .attachment_generation = 1 }, | ||
| 308 | .use_poll_recipe = false, | ||
| 309 | .target = .{ .hand = .{ | ||
| 310 | .host = "unused.invalid", | ||
| 311 | .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; dd bs=5 count=1 of=/dev/null 2>/dev/null; printf '\\221\\002\\000\\000\\0000\\n'; cat >/dev/null" }, | ||
| 312 | .asked_argv = &.{"false"}, | ||
| 313 | .cache_path = null, | ||
| 314 | .asked = true, | ||
| 315 | } }, | ||
| 316 | }); | ||
| 317 | defer job.stop(); | ||
| 318 | const result = try resultOf(job); | ||
| 319 | try std.testing.expectEqual(Phase.sessions, result.phase); | ||
| 320 | try std.testing.expectEqualStrings("0\n", result.text()); | ||
| 321 | try std.testing.expect(!result.may_have_created); | ||
| 322 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -398,7 +398,7 @@ const Events = struct { | |||
| 398 | /// Commit events sample the actual drawable even if its resize notice | 398 | /// Commit events sample the actual drawable even if its resize notice |
| 399 | /// is still behind this event in SDL's bounded queue. | 399 | /// is still behind this event in SDL's bounded queue. |
| 400 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 400 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 401 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { | 401 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.command_mode or self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 402 | self.geometry_dirty = true; | 402 | self.geometry_dirty = true; |
| 403 | try self.refreshGeometry(); | 403 | try self.refreshGeometry(); |
| 404 | } | 404 | } |
| @@ -406,7 +406,7 @@ const Events = struct { | |||
| 406 | } | 406 | } |
| 407 | fn handle(self: *Events, ev: c.SDL_Event) !bool { | 407 | fn handle(self: *Events, ev: c.SDL_Event) !bool { |
| 408 | defer self.syncCapture(); | 408 | defer self.syncCapture(); |
| 409 | std.debug.assert(self.ui.picker == null or self.ui.recovery == null); | 409 | std.debug.assert(self.ui.modalPicker() == null or self.ui.recovery == null); |
| 410 | switch (ev.type) { | 410 | switch (ev.type) { |
| 411 | c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, | 411 | c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, |
| 412 | c.SDL_EVENT_TEXT_INPUT => { | 412 | c.SDL_EVENT_TEXT_INPUT => { |
| @@ -644,7 +644,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 644 | var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } }; | 644 | var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } }; |
| 645 | defer events.deinit(); | 645 | defer events.deinit(); |
| 646 | try events.ui.relayout(); | 646 | try events.ui.relayout(); |
| 647 | if (events.ui.layout.len == 0) try events.ui.openPicker(.insert); | 647 | if (events.ui.layout.len == 0) try events.ui.open(.insert); |
| 648 | var headers: [model.max_panes]Header = @splat(.{}); | 648 | var headers: [model.max_panes]Header = @splat(.{}); |
| 649 | var popup: PopupFrame = .{}; | 649 | var popup: PopupFrame = .{}; |
| 650 | var popup_lists: quads.Lists = .{}; | 650 | var popup_lists: quads.Lists = .{}; |
| @@ -685,12 +685,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 685 | } | 685 | } |
| 686 | try events.ui.pollEnd(); | 686 | try events.ui.pollEnd(); |
| 687 | events.syncCapture(); | 687 | events.syncCapture(); |
| 688 | if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { | 688 | if (events.ui.requestReady()) { |
| 689 | events.geometry_dirty = true; | 689 | events.geometry_dirty = true; |
| 690 | try events.refreshGeometry(); | 690 | try events.refreshGeometry(); |
| 691 | events.ui.dirty = (try picker.poll()) or events.ui.dirty; | 691 | try events.ui.pollOpening(); |
| 692 | }; | 692 | } |
| 693 | try events.ui.finishPicker(); | ||
| 694 | events.ui.saveIntent(); | 693 | events.ui.saveIntent(); |
| 695 | if (hook) |*h| if (h.state) |path| { | 694 | if (hook) |*h| if (h.state) |path| { |
| 696 | defer alloc.free(path); | 695 | defer alloc.free(path); |
| @@ -731,11 +730,11 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 731 | try prepareRow(&cache, &header_row); | 730 | try prepareRow(&cache, &header_row); |
| 732 | } | 731 | } |
| 733 | popup.len = 0; | 732 | popup.len = 0; |
| 734 | if (events.ui.picker) |picker| { | 733 | if (events.ui.modalPicker()) |picker| { |
| 735 | popup.set(picker); | 734 | popup.set(picker); |
| 736 | } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events); | 735 | } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events); |
| 737 | 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); | 736 | 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); |
| 738 | if (events.ui.picker != null and events.ui.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.notice, popup.rect.w / events.ui.metrics.cell_w); | 737 | 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); |
| 739 | for (popup.lines[0..popup.len]) |*line| { | 738 | for (popup.lines[0..popup.len]) |*line| { |
| 740 | var row = line.row(); | 739 | var row = line.row(); |
| 741 | try prepareRow(&cache, &row); | 740 | try prepareRow(&cache, &row); |
| @@ -854,15 +853,15 @@ test "delayed End refusal cannot install a hidden force menu over an active pick | |||
| 854 | defer events.deinit(); | 853 | defer events.deinit(); |
| 855 | try events.ui.relayout(); | 854 | try events.ui.relayout(); |
| 856 | events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; | 855 | events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 }; |
| 857 | try events.ui.openPicker(.insert); | 856 | try events.ui.open(.insert); |
| 858 | rt.get(id).?.pump.mu.lock(); | 857 | rt.get(id).?.pump.mu.lock(); |
| 859 | rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; | 858 | rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; |
| 860 | rt.get(id).?.pump.mu.unlock(); | 859 | rt.get(id).?.pump.mu.unlock(); |
| 861 | _ = rt.poll(std.time.milliTimestamp()); | 860 | _ = rt.poll(std.time.milliTimestamp()); |
| 862 | try events.ui.pollEnd(); | 861 | try events.ui.pollEnd(); |
| 863 | try std.testing.expect(events.ui.recovery == null and events.ui.picker != null); | 862 | try std.testing.expect(events.ui.recovery == null and events.ui.modalPicker() != null); |
| 864 | try std.testing.expect(events.ui.pending_end == null); | 863 | try std.testing.expect(events.ui.pending_end == null); |
| 865 | events.ui.picker.?.level = .session_name; | 864 | events.ui.modalPicker().?.level = .session_name; |
| 866 | var ev = std.mem.zeroes(c.SDL_Event); | 865 | var ev = std.mem.zeroes(c.SDL_Event); |
| 867 | ev.key.type = c.SDL_EVENT_KEY_DOWN; | 866 | ev.key.type = c.SDL_EVENT_KEY_DOWN; |
| 868 | ev.key.key = c.SDLK_X; | 867 | ev.key.key = c.SDLK_X; |
| @@ -870,7 +869,7 @@ test "delayed End refusal cannot install a hidden force menu over an active pick | |||
| 870 | ev.text.type = c.SDL_EVENT_TEXT_INPUT; | 869 | ev.text.type = c.SDL_EVENT_TEXT_INPUT; |
| 871 | ev.text.text = "x"; | 870 | ev.text.text = "x"; |
| 872 | _ = try events.handle(ev); | 871 | _ = try events.handle(ev); |
| 873 | try std.testing.expectEqualStrings("x", events.ui.picker.?.input.items); | 872 | try std.testing.expectEqualStrings("x", events.ui.modalPicker().?.input.items); |
| 874 | try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request); | 873 | try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request); |
| 875 | } | 874 | } |
| 876 | 875 | ||
| @@ -1023,7 +1022,8 @@ const Header = struct { | |||
| 1023 | const focused = events.ui.rt.workspace.tab().focus == p.id; | 1022 | const focused = events.ui.rt.workspace.tab().focus == p.id; |
| 1024 | const pending = events.ui.rt.workspace.tab().pending; | 1023 | const pending = events.ui.rt.workspace.tab().pending; |
| 1025 | const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; | 1024 | const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false; |
| 1026 | const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v below, b beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (events.ui.picker != null and events.ui.picker.?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; | 1025 | const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick; |
| 1026 | const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: v new below, b new beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (quick) events.ui.opening.?.noticeText() else if (pending != null and pending.?.pane == p.id) (if (events.ui.modalPicker() != null and events.ui.modalPicker().?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else events.ui.notice; | ||
| 1027 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; | 1027 | const label = events.ui.rt.workspace.pane(p.id).?.identity.label; |
| 1028 | var status_buf: [48]u8 = undefined; | 1028 | var status_buf: [48]u8 = undefined; |
| 1029 | const status: []const u8 = switch (live.status.phase) { | 1029 | const status: []const u8 = switch (live.status.phase) { |
| @@ -1105,7 +1105,7 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void | |||
| 1105 | const PopupRow = struct { label: []const u8, rect: model.Rect }; | 1105 | const PopupRow = struct { label: []const u8, rect: model.Rect }; |
| 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 | 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 }; |
| 1107 | var picker_state: ?PopupState = null; | 1107 | var picker_state: ?PopupState = null; |
| 1108 | if (events.ui.picker) |picker| { | 1108 | if (events.ui.modalPicker()) |picker| { |
| 1109 | const view = picker.view(); | 1109 | const view = picker.view(); |
| 1110 | const rows = try a.alloc(PopupRow, picker.rowCount()); | 1110 | const rows = try a.alloc(PopupRow, picker.rowCount()); |
| 1111 | for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; | 1111 | for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; |
| @@ -1119,7 +1119,8 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void | |||
| 1119 | for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) }; | 1119 | for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) }; |
| 1120 | recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect }; | 1120 | recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect }; |
| 1121 | } | 1121 | } |
| 1122 | const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .panes = panes[0..events.ui.layout.len] }, .{}); | 1122 | const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick; |
| 1123 | const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (quick) events.ui.opening.?.noticeText() else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .opening = quick, .panes = panes[0..events.ui.layout.len] }, .{}); | ||
| 1123 | try writeArtifact(a, path, &.{bytes}); | 1124 | try writeArtifact(a, path, &.{bytes}); |
| 1124 | } | 1125 | } |
| 1125 | fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { | 1126 | fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { |
| @@ -1524,7 +1525,7 @@ const PopupFrame = struct { | |||
| 1524 | self.selected_line = 1; | 1525 | self.selected_line = 1; |
| 1525 | } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols); | 1526 | } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols); |
| 1526 | self.lines[self.len - 2].setText(picker.noticeText(), cols); | 1527 | self.lines[self.len - 2].setText(picker.noticeText(), cols); |
| 1527 | self.lines[self.len - 1].setText(if (editing) "Enter confirms | Esc goes back" else "Up/Down or j/k choose | Enter selects | Esc goes back", cols); | 1528 | 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); |
| 1528 | } | 1529 | } |
| 1529 | fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { | 1530 | fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void { |
| 1530 | if (self.len == 0) return; | 1531 | if (self.len == 0) return; |
| @@ -1555,7 +1556,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until | |||
| 1555 | const picker = try a.create(picker_mod.Picker); | 1556 | const picker = try a.create(picker_mod.Picker); |
| 1556 | 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 }; | 1557 | 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 }; |
| 1557 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | 1558 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; |
| 1558 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker, .wake_ctx = &wake, .wake = Wake.discovery } }; | 1559 | var events: Events = .{ .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600, .opening = picker, .wake_ctx = &wake, .wake = Wake.discovery } }; |
| 1559 | defer events.deinit(); | 1560 | defer events.deinit(); |
| 1560 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); | 1561 | try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } }); |
| 1561 | picker.level = .session_name; | 1562 | picker.level = .session_name; |
| @@ -1584,7 +1585,7 @@ test "modal Enter remains consumed through insertion and repeated keydown until | |||
| 1584 | event.key.type = c.SDL_EVENT_KEY_DOWN; | 1585 | event.key.type = c.SDL_EVENT_KEY_DOWN; |
| 1585 | event.key.key = c.SDLK_RETURN; | 1586 | event.key.key = c.SDLK_RETURN; |
| 1586 | try std.testing.expect(try events.handle(event)); | 1587 | try std.testing.expect(try events.handle(event)); |
| 1587 | try std.testing.expect(events.ui.picker == null); | 1588 | try std.testing.expect(events.ui.modalPicker() == null); |
| 1588 | try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len); | 1589 | try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len); |
| 1589 | event.key.repeat = true; | 1590 | event.key.repeat = true; |
| 1590 | try std.testing.expect(try events.handle(event)); | 1591 | try std.testing.expect(try events.handle(event)); |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -70,7 +70,7 @@ pub const Recovery = struct { | |||
| 70 | }; | 70 | }; |
| 71 | pub const Controller = struct { | 71 | pub const Controller = struct { |
| 72 | rt: *runtime.Runtime, | 72 | rt: *runtime.Runtime, |
| 73 | picker: ?*picker_mod.Picker = null, | 73 | opening: ?*picker_mod.Picker = null, |
| 74 | next_request: u64 = 1, | 74 | next_request: u64 = 1, |
| 75 | key_path: ?[]const u8 = null, | 75 | key_path: ?[]const u8 = null, |
| 76 | local_target: ?client.Target = null, | 76 | local_target: ?client.Target = null, |
| @@ -109,7 +109,16 @@ pub const Controller = struct { | |||
| 109 | self.cancelDrag(); | 109 | self.cancelDrag(); |
| 110 | self.clearSelection(); | 110 | self.clearSelection(); |
| 111 | self.modal_held.deinit(self.rt.alloc); | 111 | self.modal_held.deinit(self.rt.alloc); |
| 112 | if (self.picker) |picker| picker.deinit(); | 112 | if (self.opening) |opening| { |
| 113 | opening.cancel(); | ||
| 114 | if (opening.mode == .quick) std.debug.print("muxg: {s}\n", .{opening.noticeText()}); | ||
| 115 | opening.deinit(); | ||
| 116 | } | ||
| 117 | } | ||
| 118 | /// The modal view of the active request; quick opening leaves input live. | ||
| 119 | pub fn modalPicker(self: *const Controller) ?*picker_mod.Picker { | ||
| 120 | const opening = self.opening orelse return null; | ||
| 121 | return if (opening.mode == .quick) null else opening; | ||
| 113 | } | 122 | } |
| 114 | pub fn hasPointerCapture(self: *const Controller) bool { | 123 | pub fn hasPointerCapture(self: *const Controller) bool { |
| 115 | return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; | 124 | return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; |
| @@ -207,12 +216,17 @@ pub const Controller = struct { | |||
| 207 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; | 216 | self.intent_dirty = self.intent_dirty or before != ws.tab().focus; |
| 208 | } else switch (key) { | 217 | } else switch (key) { |
| 209 | .v, .b, .enter, .keypad_enter => { | 218 | .v, .b, .enter, .keypad_enter => { |
| 210 | if (key == .v or key == .b) ws.arm(if (key == .v) .stacked else .beside); | 219 | if (self.opening != null) { |
| 211 | self.openPicker(.insert) catch |err| { | ||
| 212 | self.notice = @errorName(err); | ||
| 213 | self.dirty = true; | 220 | self.dirty = true; |
| 214 | return; | 221 | return; |
| 215 | }; | 222 | } |
| 223 | if (key == .v or key == .b) { | ||
| 224 | ws.arm(if (key == .v) .stacked else .beside); | ||
| 225 | self.open(.quick) catch |err| { | ||
| 226 | ws.cancel(); | ||
| 227 | self.setNotice(@errorName(err)); | ||
| 228 | }; | ||
| 229 | } else self.open(.insert) catch |err| self.setNotice(@errorName(err)); | ||
| 216 | }, | 230 | }, |
| 217 | .r => self.resize_mode = true, | 231 | .r => self.resize_mode = true, |
| 218 | .d => if (ws.tab().focus) |id| try self.detach(id), | 232 | .d => if (ws.tab().focus) |id| try self.detach(id), |
| @@ -221,33 +235,67 @@ pub const Controller = struct { | |||
| 221 | self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key }; | 235 | self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key }; |
| 222 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); | 236 | self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText()); |
| 223 | }, | 237 | }, |
| 224 | .escape => ws.cancel(), | 238 | .escape => { |
| 225 | else => self.notice = "v below, b beside, hjkl focus, r resize, d detach, x end, p actions, Enter add", | 239 | if (self.opening) |opening| opening.cancel(); |
| 240 | try self.finishOpening(); | ||
| 241 | ws.cancel(); | ||
| 242 | }, | ||
| 243 | else => self.notice = "v new below, b new beside, hjkl focus, r resize, d detach, x end, p actions, Enter picks session", | ||
| 226 | } | 244 | } |
| 227 | self.dirty = true; | 245 | self.dirty = true; |
| 228 | } | 246 | } |
| 229 | 247 | ||
| 230 | pub fn finishPicker(self: *Controller) !void { | 248 | pub fn requestReady(self: *const Controller) bool { |
| 231 | if (self.picker) |picker| if (picker.closed) { | 249 | const opening = self.opening orelse return false; |
| 232 | const inserted = picker.inserted; | 250 | const job = opening.job orelse return false; |
| 233 | picker.deinit(); | 251 | return job.done.load(.acquire); |
| 234 | self.picker = null; | 252 | } |
| 253 | /// Geometry is refreshed by the frame before completed requests insert. | ||
| 254 | pub fn pollOpening(self: *Controller) !void { | ||
| 255 | const opening = self.opening orelse return; | ||
| 256 | self.dirty = (try opening.poll()) or self.dirty; | ||
| 257 | try self.finishOpening(); | ||
| 258 | } | ||
| 259 | fn finishOpening(self: *Controller) !void { | ||
| 260 | const opening = self.opening orelse return; | ||
| 261 | if (!opening.closed) return; | ||
| 262 | const inserted = opening.inserted; | ||
| 263 | if (opening.mode == .quick) { | ||
| 235 | if (inserted) { | 264 | if (inserted) { |
| 236 | try self.relayout(); | 265 | self.notice = ""; |
| 237 | self.intent_dirty = true; | 266 | } else { |
| 267 | self.setNotice(opening.noticeText()); | ||
| 268 | 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(); | ||
| 238 | } | 269 | } |
| 239 | self.dirty = true; | 270 | } |
| 240 | }; | 271 | opening.deinit(); |
| 272 | self.opening = null; | ||
| 273 | if (inserted) { | ||
| 274 | self.cancelDrag(); | ||
| 275 | self.clearSelection(); | ||
| 276 | try self.relayout(); | ||
| 277 | self.intent_dirty = true; | ||
| 278 | } | ||
| 279 | self.dirty = true; | ||
| 241 | } | 280 | } |
| 242 | pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { | 281 | |
| 282 | pub fn open(self: *Controller, mode: picker_mod.Picker.Mode) !void { | ||
| 283 | if (self.opening != null) { | ||
| 284 | self.setNotice("A pane is opening; Esc cancels it"); | ||
| 285 | return; | ||
| 286 | } | ||
| 243 | self.cancelDrag(); | 287 | self.cancelDrag(); |
| 244 | self.clearSelection(); | 288 | self.clearSelection(); |
| 245 | self.recovery = null; | 289 | self.recovery = null; |
| 246 | self.notice = ""; | 290 | self.notice = ""; |
| 247 | const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); | 291 | const actual_mode = if (mode == .quick and self.rt.workspace.tab().focus == null) .insert else mode; |
| 248 | errdefer picker.deinit(); | 292 | self.opening = blk: { |
| 249 | if (picker.origin == null) if (self.local_target) |target| try picker.includeTarget(target); | 293 | const opening = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, actual_mode); |
| 250 | self.picker = picker; | 294 | errdefer opening.deinit(); |
| 295 | if (opening.origin == null) if (self.local_target) |target| try opening.includeTarget(target); | ||
| 296 | break :blk opening; | ||
| 297 | }; | ||
| 298 | try self.finishOpening(); | ||
| 251 | self.dirty = true; | 299 | self.dirty = true; |
| 252 | } | 300 | } |
| 253 | pub fn setNotice(self: *Controller, text: []const u8) void { | 301 | pub fn setNotice(self: *Controller, text: []const u8) void { |
| @@ -259,6 +307,10 @@ pub const Controller = struct { | |||
| 259 | pub fn detach(self: *Controller, id: model.PaneId) !void { | 307 | pub fn detach(self: *Controller, id: model.PaneId) !void { |
| 260 | self.cancelDrag(); | 308 | self.cancelDrag(); |
| 261 | self.resize_mode = false; | 309 | self.resize_mode = false; |
| 310 | if (self.opening) |opening| if (opening.mode == .quick and opening.origin.?.pane == id) { | ||
| 311 | opening.cancel(); | ||
| 312 | try self.finishOpening(); | ||
| 313 | }; | ||
| 262 | if (self.pending_end) |pending| if (pending.key.pane == id) { | 314 | if (self.pending_end) |pending| if (pending.key.pane == id) { |
| 263 | self.pending_end = null; | 315 | self.pending_end = null; |
| 264 | self.setNotice("Pane detached; the pending End outcome is unknown"); | 316 | self.setNotice("Pane detached; the pending End outcome is unknown"); |
| @@ -298,7 +350,7 @@ pub const Controller = struct { | |||
| 298 | .accepted => try self.detach(pending.key.pane), | 350 | .accepted => try self.detach(pending.key.pane), |
| 299 | .refused => { | 351 | .refused => { |
| 300 | self.setNotice(result.reasonText()); | 352 | self.setNotice(result.reasonText()); |
| 301 | if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.picker == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) { | 353 | if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.opening == null and self.recovery == null and !self.resize_mode and self.drag == null and !self.command_mode) { |
| 302 | self.recovery = .{ .kind = .force_end, .key = pending.key }; | 354 | self.recovery = .{ .kind = .force_end, .key = pending.key }; |
| 303 | self.recovery.?.setNotice(result.reasonText()); | 355 | self.recovery.?.setNotice(result.reasonText()); |
| 304 | } | 356 | } |
| @@ -332,7 +384,7 @@ pub const Controller = struct { | |||
| 332 | self.intent_dirty = true; | 384 | self.intent_dirty = true; |
| 333 | } else { | 385 | } else { |
| 334 | _ = self.rt.workspace.focus(menu.key.pane); | 386 | _ = self.rt.workspace.focus(menu.key.pane); |
| 335 | try self.openPicker(.replace); | 387 | try self.open(.replace); |
| 336 | } | 388 | } |
| 337 | }, | 389 | }, |
| 338 | 2 => try self.detach(menu.key.pane), | 390 | 2 => try self.detach(menu.key.pane), |
| @@ -362,9 +414,9 @@ pub const Controller = struct { | |||
| 362 | try self.recoveryAction(); | 414 | try self.recoveryAction(); |
| 363 | break; | 415 | break; |
| 364 | }; | 416 | }; |
| 365 | } else if (self.picker) |picker| { | 417 | } else if (self.modalPicker()) |picker| { |
| 366 | try picker.click(x, y); | 418 | try picker.click(x, y); |
| 367 | try self.finishPicker(); | 419 | try self.finishOpening(); |
| 368 | } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { | 420 | } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { |
| 369 | const d = self.layout.divider(id).?; | 421 | const d = self.layout.divider(id).?; |
| 370 | const position = if (d.direction == .beside) x else y; | 422 | const position = if (d.direction == .beside) x else y; |
| @@ -380,14 +432,14 @@ pub const Controller = struct { | |||
| 380 | self.selection_key = live.key; | 432 | self.selection_key = live.key; |
| 381 | self.selection_version = live.snapshot_version; | 433 | self.selection_version = live.snapshot_version; |
| 382 | } | 434 | } |
| 383 | } else if (self.layout.len == 0) try self.openPicker(.insert); | 435 | } else if (self.layout.len == 0) try self.open(.insert); |
| 384 | } | 436 | } |
| 385 | self.dirty = true; | 437 | self.dirty = true; |
| 386 | } | 438 | } |
| 387 | 439 | ||
| 388 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void { | 440 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void { |
| 389 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; | 441 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; |
| 390 | if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { | 442 | if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { |
| 391 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); | 443 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); |
| 392 | return; | 444 | return; |
| 393 | } | 445 | } |
| @@ -429,7 +481,7 @@ pub const Controller = struct { | |||
| 429 | return; | 481 | return; |
| 430 | } | 482 | } |
| 431 | if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y); | 483 | if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y); |
| 432 | if (x < 0 or y < 0 or mods.shift or self.picker != null or self.recovery != null or self.resize_mode or self.command_mode) return; | 484 | if (x < 0 or y < 0 or mods.shift or self.modalPicker() != null or self.recovery != null or self.resize_mode or self.command_mode) return; |
| 433 | const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return; | 485 | const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return; |
| 434 | if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return; | 486 | if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return; |
| 435 | const p = self.layout.get(id) orelse return; | 487 | const p = self.layout.get(id) orelse return; |
| @@ -534,7 +586,7 @@ pub const Controller = struct { | |||
| 534 | /// Semantic wheel entry point. Transport routing is deliberately deferred | 586 | /// Semantic wheel entry point. Transport routing is deliberately deferred |
| 535 | /// until the pump has sampled the pane's current terminal modes. | 587 | /// until the pump has sampled the pane's current terminal modes. |
| 536 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void { | 588 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void { |
| 537 | if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; | 589 | if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; |
| 538 | const id = self.layout.hit(x, y) orelse return; | 590 | const id = self.layout.hit(x, y) orelse return; |
| 539 | const placement = self.layout.get(id) orelse return; | 591 | const placement = self.layout.get(id) orelse return; |
| 540 | if (!placement.content.contains(x, y) or placement.cols == 0 or placement.rows == 0 or self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return; | 592 | if (!placement.content.contains(x, y) or placement.cols == 0 or placement.rows == 0 or self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return; |
| @@ -581,7 +633,7 @@ pub const Controller = struct { | |||
| 581 | } | 633 | } |
| 582 | pub fn textInput(self: *Controller, text: []const u8) !void { | 634 | pub fn textInput(self: *Controller, text: []const u8) !void { |
| 583 | if (!self.suppress_text) { | 635 | if (!self.suppress_text) { |
| 584 | if (self.picker) |picker| { | 636 | if (self.modalPicker()) |picker| { |
| 585 | try picker.text(text); | 637 | try picker.text(text); |
| 586 | self.dirty = true; | 638 | self.dirty = true; |
| 587 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { | 639 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { |
| @@ -596,7 +648,7 @@ pub const Controller = struct { | |||
| 596 | pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void { | 648 | pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void { |
| 597 | self.consumeShortcut(key); | 649 | self.consumeShortcut(key); |
| 598 | if (text.len == 0) return; | 650 | if (text.len == 0) return; |
| 599 | if (self.picker) |picker| { | 651 | if (self.modalPicker()) |picker| { |
| 600 | try picker.text(text); | 652 | try picker.text(text); |
| 601 | self.dirty = true; | 653 | self.dirty = true; |
| 602 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { | 654 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { |
| @@ -627,19 +679,19 @@ pub const Controller = struct { | |||
| 627 | self.fb_w = w; | 679 | self.fb_w = w; |
| 628 | self.fb_h = h; | 680 | self.fb_h = h; |
| 629 | self.metrics = metrics; | 681 | self.metrics = metrics; |
| 630 | if (self.picker) |picker| { | 682 | if (self.opening) |opening| { |
| 631 | picker.width = @intCast(@max(w, 0)); | 683 | opening.width = @intCast(@max(w, 0)); |
| 632 | picker.height = @intCast(@max(h, 0)); | 684 | opening.height = @intCast(@max(h, 0)); |
| 633 | picker.metrics = metrics; | 685 | opening.metrics = metrics; |
| 634 | } | 686 | } |
| 635 | try self.relayout(); | 687 | try self.relayout(); |
| 636 | } | 688 | } |
| 637 | pub fn keyDown(self: *Controller, input: KeyDown) !void { | 689 | pub fn keyDown(self: *Controller, input: KeyDown) !void { |
| 638 | std.debug.assert(self.picker == null or self.recovery == null); | 690 | std.debug.assert(self.modalPicker() == null or self.recovery == null); |
| 639 | self.suppress_text = false; | 691 | self.suppress_text = false; |
| 640 | const key = input.code; | 692 | const key = input.code; |
| 641 | const kind = input.kind; | 693 | const kind = input.kind; |
| 642 | if (!self.resize_mode and self.recovery == null and self.picker == null and self.modal_held.contains(key)) { | 694 | if (!self.resize_mode and self.recovery == null and self.modalPicker() == null and self.modal_held.contains(key)) { |
| 643 | self.suppress_text = true; | 695 | self.suppress_text = true; |
| 644 | return; | 696 | return; |
| 645 | } | 697 | } |
| @@ -688,8 +740,14 @@ pub const Controller = struct { | |||
| 688 | self.dirty = true; | 740 | self.dirty = true; |
| 689 | return; | 741 | return; |
| 690 | } | 742 | } |
| 691 | if (self.picker) |picker| { | 743 | if (self.modalPicker()) |picker| { |
| 692 | try self.modal_held.put(self.rt.alloc, key, {}); | 744 | try self.modal_held.put(self.rt.alloc, key, {}); |
| 745 | if (!input.modified and (kind == .v or kind == .b) and picker.mode == .insert and (picker.level == .hosts or picker.level == .sessions)) { | ||
| 746 | picker.setDirection(if (kind == .v) .stacked else .beside); | ||
| 747 | self.suppress_text = true; | ||
| 748 | self.dirty = true; | ||
| 749 | return; | ||
| 750 | } | ||
| 693 | const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions); | 751 | const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions); |
| 694 | if (mapped) |k| { | 752 | if (mapped) |k| { |
| 695 | self.suppress_text = true; | 753 | self.suppress_text = true; |
| @@ -697,7 +755,7 @@ pub const Controller = struct { | |||
| 697 | try picker.key(k); | 755 | try picker.key(k); |
| 698 | } else if (input.modified) self.suppress_text = true; | 756 | } else if (input.modified) self.suppress_text = true; |
| 699 | self.dirty = true; | 757 | self.dirty = true; |
| 700 | try self.finishPicker(); | 758 | try self.finishOpening(); |
| 701 | return; | 759 | return; |
| 702 | } | 760 | } |
| 703 | if (input.prefix) { | 761 | if (input.prefix) { |
| @@ -715,6 +773,10 @@ pub const Controller = struct { | |||
| 715 | self.consumed_key = key; | 773 | self.consumed_key = key; |
| 716 | self.suppress_text = true; | 774 | self.suppress_text = true; |
| 717 | try self.command(kind); | 775 | try self.command(kind); |
| 776 | } else if (kind == .escape and self.opening != null) { | ||
| 777 | self.consumeShortcut(key); | ||
| 778 | self.opening.?.cancel(); | ||
| 779 | try self.finishOpening(); | ||
| 718 | } else if (kind == .escape and self.rt.workspace.tab().pending != null) { | 780 | } else if (kind == .escape and self.rt.workspace.tab().pending != null) { |
| 719 | self.rt.workspace.cancel(); | 781 | self.rt.workspace.cancel(); |
| 720 | self.dirty = true; | 782 | self.dirty = true; |
| @@ -776,13 +838,13 @@ test "paste shortcut follows the active text destination" { | |||
| 776 | defer picker.arena.deinit(); | 838 | defer picker.arena.deinit(); |
| 777 | defer picker.input.deinit(a); | 839 | defer picker.input.deinit(a); |
| 778 | picker.level = .session_name; | 840 | picker.level = .session_name; |
| 779 | ui.picker = &picker; | 841 | ui.opening = &picker; |
| 780 | defer ui.picker = null; | 842 | defer ui.opening = null; |
| 781 | try ui.pasteShortcut(86, "new-shell"); | 843 | try ui.pasteShortcut(86, "new-shell"); |
| 782 | try std.testing.expectEqualStrings("new-shell", picker.input.items); | 844 | try std.testing.expectEqualStrings("new-shell", picker.input.items); |
| 783 | try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key); | 845 | try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key); |
| 784 | ui.keyUp(86); | 846 | ui.keyUp(86); |
| 785 | ui.picker = null; | 847 | ui.opening = null; |
| 786 | ui.command_mode = true; | 848 | ui.command_mode = true; |
| 787 | try ui.pasteShortcut(86, "not terminal input"); | 849 | try ui.pasteShortcut(86, "not terminal input"); |
| 788 | try std.testing.expect(ui.command_mode and ui.consumed_key == 86); | 850 | try std.testing.expect(ui.command_mode and ui.consumed_key == 86); |
| @@ -833,29 +895,53 @@ test "controller modal input takes precedence over the prefix" { | |||
| 833 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); | 895 | try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text); |
| 834 | } | 896 | } |
| 835 | 897 | ||
| 836 | test "v opens below and b opens beside while Esc preserves then cancels direction" { | 898 | test "quick splits are nonmodal and cancellable; picker directions remain explicit" { |
| 837 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | 899 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); |
| 838 | defer rt.deinit(); | 900 | defer rt.deinit(); |
| 839 | var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; | 901 | var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 }; |
| 840 | defer ui.deinit(); | 902 | defer ui.deinit(); |
| 841 | 903 | ||
| 842 | _ = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); | 904 | // An empty workspace still needs an explicit target choice. |
| 843 | try ui.command(.v); | 905 | try ui.command(.v); |
| 844 | try std.testing.expect(ui.picker != null); | 906 | try std.testing.expect(ui.modalPicker() != null); |
| 845 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 846 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | 907 | try ui.keyDown(.{ .code = 27, .kind = .escape }); |
| 847 | ui.keyUp(27); | 908 | ui.keyUp(27); |
| 848 | try std.testing.expect(ui.picker == null); | ||
| 849 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 850 | 909 | ||
| 910 | const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics); | ||
| 911 | try ui.command(.v); | ||
| 912 | try std.testing.expect(ui.modalPicker() == null and ui.opening != null); | ||
| 913 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 914 | try std.testing.expectEqual(id, ui.opening.?.origin.?.pane); | ||
| 915 | try ui.keyDown(.{ .code = 65 }); | ||
| 916 | try std.testing.expect(!ui.suppress_text and ui.modal_held.count() == 0); | ||
| 917 | ui.keyUp(65); | ||
| 918 | const request = ui.opening.?.ticket; | ||
| 919 | try ui.command(.b); | ||
| 920 | try std.testing.expectEqual(request, ui.opening.?.ticket); | ||
| 851 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | 921 | try ui.keyDown(.{ .code = 27, .kind = .escape }); |
| 852 | ui.keyUp(27); | 922 | ui.keyUp(27); |
| 853 | try std.testing.expect(rt.workspace.tab().pending == null); | 923 | try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null); |
| 854 | 924 | ||
| 855 | try ui.command(.b); | 925 | try ui.command(.b); |
| 856 | try std.testing.expectEqual(model.Direction.beside, rt.workspace.tab().pending.?.direction); | 926 | try std.testing.expectEqual(model.Direction.beside, ui.opening.?.pending.?.direction); |
| 927 | try ui.command(.escape); | ||
| 928 | |||
| 929 | try ui.command(.enter); | ||
| 930 | try ui.keyDown(.{ .code = 118, .kind = .v }); | ||
| 931 | ui.keyUp(118); | ||
| 932 | try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction); | ||
| 933 | try std.testing.expectEqual(rt.workspace.tab().pending, ui.modalPicker().?.pending); | ||
| 857 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | 934 | try ui.keyDown(.{ .code = 27, .kind = .escape }); |
| 858 | ui.keyUp(27); | 935 | ui.keyUp(27); |
| 936 | try std.testing.expect(ui.modalPicker() == null and rt.workspace.tab().pending != null); | ||
| 937 | try ui.keyDown(.{ .code = 27, .kind = .escape }); | ||
| 938 | ui.keyUp(27); | ||
| 939 | try std.testing.expect(rt.workspace.tab().pending == null); | ||
| 940 | |||
| 941 | try ui.updateGeometry(1, 1, ui.metrics); | ||
| 942 | try ui.command(.b); | ||
| 943 | try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null); | ||
| 944 | try std.testing.expectEqualStrings("TooSmall", ui.notice); | ||
| 859 | } | 945 | } |
| 860 | 946 | ||
| 861 | test "beginEnd records pending request without opening an ending modal" { | 947 | test "beginEnd records pending request without opening an ending modal" { |
src/gui/picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | //! Modal host/session selection policy. No window calls or transport IO run | 1 | //! Host/session opening policy, with a modal picker or automatic naming on |
| 2 | //! here: discovery jobs own IO, while this object owns their UI generations. | 2 | //! the focused target. Discovery jobs own IO; requests retain their destination |
| 3 | //! and attachment generation until creation and pane insertion finish. | ||
| 3 | const std = @import("std"); | 4 | const std = @import("std"); |
| 4 | const client = @import("client"); | 5 | const client = @import("client"); |
| 5 | const proto = @import("term").protocol; | 6 | const proto = @import("term").protocol; |
| @@ -20,7 +21,7 @@ pub const View = struct { | |||
| 20 | } | 21 | } |
| 21 | }; | 22 | }; |
| 22 | pub const Picker = struct { | 23 | pub const Picker = struct { |
| 23 | pub const Mode = enum { insert, replace }; | 24 | pub const Mode = enum { insert, replace, quick }; |
| 24 | alloc: std.mem.Allocator, | 25 | alloc: std.mem.Allocator, |
| 25 | arena: std.heap.ArenaAllocator, | 26 | arena: std.heap.ArenaAllocator, |
| 26 | rt: *runtime.Runtime, | 27 | rt: *runtime.Runtime, |
| @@ -50,21 +51,32 @@ pub const Picker = struct { | |||
| 50 | metrics: model.Metrics, | 51 | metrics: model.Metrics, |
| 51 | closed: bool = false, | 52 | closed: bool = false, |
| 52 | inserted: bool = false, | 53 | inserted: bool = false, |
| 54 | collisions: usize = 0, | ||
| 53 | 55 | ||
| 54 | pub fn init(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void) !*Picker { | 56 | pub fn init(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void) !*Picker { |
| 55 | return initMode(alloc, rt, next_generation, key_path, width, height, metrics, wake_ctx, wake, .insert); | 57 | return initMode(alloc, rt, next_generation, key_path, width, height, metrics, wake_ctx, wake, .insert); |
| 56 | } | 58 | } |
| 57 | pub fn initMode(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void, mode: Mode) !*Picker { | 59 | pub fn initMode(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void, mode: Mode) !*Picker { |
| 58 | if (mode == .insert and rt.workspace.tab().pending == null) rt.workspace.arm(.beside); | 60 | if (mode != .replace and rt.workspace.tab().pending == null) rt.workspace.arm(.beside); |
| 59 | const pending = rt.workspace.tab().pending; | 61 | const pending = rt.workspace.tab().pending; |
| 60 | const id = if (mode == .replace) rt.workspace.tab().focus else if (pending) |p| p.pane else null; | 62 | const id = if (mode == .replace) rt.workspace.tab().focus else if (pending) |p| p.pane else null; |
| 61 | const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null; | 63 | const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null; |
| 62 | if (mode == .replace and pane == null) return error.MissingPane; | 64 | if (mode != .insert and pane == null) return error.MissingPane; |
| 63 | const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null; | 65 | const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null; |
| 64 | const self = try alloc.create(Picker); | 66 | const self = try alloc.create(Picker); |
| 65 | 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 }; | 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 }; |
| 66 | next_generation.* += 1; | 68 | next_generation.* += 1; |
| 67 | errdefer self.deinit(); | 69 | errdefer self.deinit(); |
| 70 | if (mode == .quick) { | ||
| 71 | // Check geometry before any request, and retain the exact target, | ||
| 72 | // including custom socket, key and SSH arguments. | ||
| 73 | _ = try self.preflight(); | ||
| 74 | 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..]; | ||
| 76 | self.notice_len = text_.len; | ||
| 77 | try self.list(); | ||
| 78 | return self; | ||
| 79 | } | ||
| 68 | const a = self.arena.allocator(); | 80 | const a = self.arena.allocator(); |
| 69 | self.catalogue = client.hosts.statePath(a) catch null; | 81 | self.catalogue = client.hosts.statePath(a) catch null; |
| 70 | if (self.catalogue) |path| { | 82 | if (self.catalogue) |path| { |
| @@ -101,7 +113,7 @@ pub const Picker = struct { | |||
| 101 | if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) }); | 113 | if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) }); |
| 102 | } | 114 | } |
| 103 | pub fn deinit(self: *Picker) void { | 115 | pub fn deinit(self: *Picker) void { |
| 104 | if (self.job) |job| job.stop(); | 116 | if (self.job != null) self.cancel(); |
| 105 | self.input.deinit(self.alloc); | 117 | self.input.deinit(self.alloc); |
| 106 | self.arena.deinit(); | 118 | self.arena.deinit(); |
| 107 | self.alloc.destroy(self); | 119 | self.alloc.destroy(self); |
| @@ -183,7 +195,32 @@ pub const Picker = struct { | |||
| 183 | return; | 195 | return; |
| 184 | }; | 196 | }; |
| 185 | } | 197 | } |
| 198 | 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; | ||
| 200 | if (self.pending) |*pending| { | ||
| 201 | pending.direction = direction; | ||
| 202 | self.rt.workspace.tab().pending = pending.*; | ||
| 203 | } | ||
| 204 | } | ||
| 205 | pub fn cancel(self: *Picker) void { | ||
| 206 | self.notice_len = 0; | ||
| 207 | self.level = .hosts; | ||
| 208 | self.selected = self.host_index; | ||
| 209 | self.closed = self.mode == .quick; | ||
| 210 | if (self.closed) self.setNotice("Pane opening cancelled"); | ||
| 211 | if (self.job) |job| { | ||
| 212 | job.cancel(); | ||
| 213 | const result = self.finishJob(); | ||
| 214 | self.ticket.generation = self.next_generation.*; | ||
| 215 | self.next_generation.* += 1; | ||
| 216 | if (result.may_have_created) { | ||
| 217 | self.level = .session_name; | ||
| 218 | self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry."); | ||
| 219 | } | ||
| 220 | } | ||
| 221 | } | ||
| 186 | fn back(self: *Picker) !void { | 222 | fn back(self: *Picker) !void { |
| 223 | if (self.mode == .quick) return self.cancel(); | ||
| 187 | self.notice_len = 0; | 224 | self.notice_len = 0; |
| 188 | switch (self.level) { | 225 | switch (self.level) { |
| 189 | .hosts => self.closed = true, | 226 | .hosts => self.closed = true, |
| @@ -192,22 +229,7 @@ pub const Picker = struct { | |||
| 192 | self.selected = self.host_index; | 229 | self.selected = self.host_index; |
| 193 | }, | 230 | }, |
| 194 | .session_name => try self.list(), | 231 | .session_name => try self.list(), |
| 195 | .busy => { | 232 | .busy => self.cancel(), |
| 196 | const job = self.job.?; | ||
| 197 | job.cancel(); | ||
| 198 | const result = job.join(); | ||
| 199 | job.stop(); | ||
| 200 | self.job = null; | ||
| 201 | self.ticket.generation = self.next_generation.*; | ||
| 202 | self.next_generation.* += 1; | ||
| 203 | if (result.may_have_created) { | ||
| 204 | self.level = .session_name; | ||
| 205 | self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry."); | ||
| 206 | } else { | ||
| 207 | self.level = .hosts; | ||
| 208 | self.selected = self.host_index; | ||
| 209 | } | ||
| 210 | }, | ||
| 211 | } | 233 | } |
| 212 | } | 234 | } |
| 213 | fn choose(self: *Picker) !void { | 235 | fn choose(self: *Picker) !void { |
| @@ -256,21 +278,23 @@ pub const Picker = struct { | |||
| 256 | self.host_index = self.hosts.items.len - 1; | 278 | self.host_index = self.hosts.items.len - 1; |
| 257 | try self.list(); | 279 | try self.list(); |
| 258 | }, | 280 | }, |
| 259 | .session_name => { | 281 | .session_name => try self.create(), |
| 260 | const name = self.input.items; | ||
| 261 | if (!proto.validSessionName(name)) { | ||
| 262 | self.setNotice("Choose a valid session name (no spaces)"); | ||
| 263 | return; | ||
| 264 | } | ||
| 265 | const placement = self.preflight() catch |err| { | ||
| 266 | self.setNotice(@errorName(err)); | ||
| 267 | return; | ||
| 268 | }; | ||
| 269 | try self.start(.{ .create = .{ .name = name, .cols = placement.cols, .rows = placement.rows } }); | ||
| 270 | }, | ||
| 271 | .busy => {}, | 282 | .busy => {}, |
| 272 | } | 283 | } |
| 273 | } | 284 | } |
| 285 | fn create(self: *Picker) !void { | ||
| 286 | const name = self.input.items; | ||
| 287 | if (!proto.validSessionName(name)) { | ||
| 288 | self.setNotice("Choose a valid session name (no spaces)"); | ||
| 289 | return; | ||
| 290 | } | ||
| 291 | const placement = self.preflight() catch |err| { | ||
| 292 | self.setNotice(@errorName(err)); | ||
| 293 | self.closed = self.mode == .quick; | ||
| 294 | return; | ||
| 295 | }; | ||
| 296 | try self.start(.{ .create = .{ .name = name, .cols = placement.cols, .rows = placement.rows } }); | ||
| 297 | } | ||
| 274 | fn validOrigin(self: *Picker) bool { | 298 | fn validOrigin(self: *Picker) bool { |
| 275 | if (self.rt.workspace.active_tab_id != self.origin_tab) return false; | 299 | if (self.rt.workspace.active_tab_id != self.origin_tab) return false; |
| 276 | if (self.origin) |origin| { | 300 | if (self.origin) |origin| { |
| @@ -303,16 +327,22 @@ pub const Picker = struct { | |||
| 303 | return; | 327 | return; |
| 304 | }; | 328 | }; |
| 305 | } else { | 329 | } else { |
| 330 | const focus = self.rt.workspace.tab().focus; | ||
| 306 | _ = self.rt.addWithPolicy(target, name, self.width, self.height, self.metrics, true) catch |err| { | 331 | _ = self.rt.addWithPolicy(target, name, self.width, self.height, self.metrics, true) catch |err| { |
| 307 | self.insertionFailed(err, created); | 332 | self.insertionFailed(err, created); |
| 308 | return; | 333 | return; |
| 309 | }; | 334 | }; |
| 335 | // Typing may have moved to a different pane during discovery. | ||
| 336 | if (self.mode == .quick and focus != self.origin.?.pane) if (focus) |id| { | ||
| 337 | _ = self.rt.workspace.focus(id); | ||
| 338 | }; | ||
| 310 | } | 339 | } |
| 311 | self.inserted = true; | 340 | self.inserted = true; |
| 312 | self.closed = true; | 341 | self.closed = true; |
| 313 | } | 342 | } |
| 314 | fn insertionFailed(self: *Picker, err: anyerror, created: bool) void { | 343 | fn insertionFailed(self: *Picker, err: anyerror, created: bool) void { |
| 315 | if (created) self.outcomeNotice(true, "Pane insertion failed. Esc refreshes available sessions.") else self.setNotice(@errorName(err)); | 344 | if (created) self.outcomeNotice(true, "Pane insertion failed. Esc refreshes available sessions.") else self.setNotice(@errorName(err)); |
| 345 | self.closed = self.mode == .quick; | ||
| 316 | } | 346 | } |
| 317 | fn list(self: *Picker) !void { | 347 | fn list(self: *Picker) !void { |
| 318 | if (self.hosts.items[self.host_index].target == null) { | 348 | if (self.hosts.items[self.host_index].target == null) { |
| @@ -327,8 +357,9 @@ pub const Picker = struct { | |||
| 327 | std.debug.assert(self.job == null); | 357 | std.debug.assert(self.job == null); |
| 328 | self.ticket.generation = self.next_generation.*; | 358 | self.ticket.generation = self.next_generation.*; |
| 329 | self.next_generation.* += 1; | 359 | self.next_generation.* += 1; |
| 330 | self.job = discovery.Job.start(self.alloc, .{ .ticket = self.ticket, .target = self.hosts.items[self.host_index].target.?, .operation = operation, .wake_ctx = self.wake_ctx, .wake = self.wake }) catch |err| { | 360 | self.job = discovery.Job.start(self.alloc, .{ .ticket = self.ticket, .target = self.hosts.items[self.host_index].target.?, .operation = operation, .use_poll_recipe = self.mode != .quick, .wake_ctx = self.wake_ctx, .wake = self.wake }) catch |err| { |
| 331 | self.setNotice(@errorName(err)); | 361 | self.setNotice(@errorName(err)); |
| 362 | self.closed = self.mode == .quick; | ||
| 332 | return; | 363 | return; |
| 333 | }; | 364 | }; |
| 334 | self.level = .busy; | 365 | self.level = .busy; |
| @@ -336,21 +367,33 @@ pub const Picker = struct { | |||
| 336 | pub fn poll(self: *Picker) !bool { | 367 | pub fn poll(self: *Picker) !bool { |
| 337 | const job = self.job orelse return false; | 368 | const job = self.job orelse return false; |
| 338 | if (!job.done.load(.acquire)) return false; | 369 | if (!job.done.load(.acquire)) return false; |
| 339 | const result = job.join(); | ||
| 340 | const creating = job.opts.operation == .create; | 370 | const creating = job.opts.operation == .create; |
| 371 | try self.applyResult(self.finishJob(), creating); | ||
| 372 | return true; | ||
| 373 | } | ||
| 374 | fn finishJob(self: *Picker) discovery.Result { | ||
| 375 | const job = self.job.?; | ||
| 376 | const result = job.join(); | ||
| 341 | job.stop(); | 377 | job.stop(); |
| 342 | self.job = null; | 378 | self.job = null; |
| 343 | try self.applyResult(result, creating); | 379 | return result; |
| 344 | return true; | ||
| 345 | } | 380 | } |
| 346 | fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void { | 381 | fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void { |
| 347 | if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) { | 382 | if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) { |
| 348 | self.level = .hosts; | 383 | self.level = .hosts; |
| 349 | 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"); | 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"); |
| 385 | self.closed = self.mode == .quick; | ||
| 350 | return; | 386 | return; |
| 351 | } | 387 | } |
| 352 | switch (result.phase) { | 388 | switch (result.phase) { |
| 353 | .sessions => { | 389 | .sessions => { |
| 390 | if (self.mode == .quick) { | ||
| 391 | var buf: [proto.session_name_max]u8 = undefined; | ||
| 392 | self.input.clearRetainingCapacity(); | ||
| 393 | try self.input.appendSlice(self.alloc, client.nextFreeName(&buf, result.text())); | ||
| 394 | try self.create(); | ||
| 395 | return; | ||
| 396 | } | ||
| 354 | self.session_count = 0; | 397 | self.session_count = 0; |
| 355 | var iter = proto.sessionsIter(result.text()); | 398 | var iter = proto.sessionsIter(result.text()); |
| 356 | while (iter.next()) |name| { | 399 | while (iter.next()) |name| { |
| @@ -366,16 +409,33 @@ pub const Picker = struct { | |||
| 366 | self.level = .session_name; | 409 | self.level = .session_name; |
| 367 | try self.insert(self.input.items, true); | 410 | try self.insert(self.input.items, true); |
| 368 | }, | 411 | }, |
| 412 | .exists => { | ||
| 413 | // A definite collision is safe to retry. A lost reply is not: | ||
| 414 | // it may already have created the shell under this name. | ||
| 415 | if (self.mode == .quick and self.collisions < proto.sessions_max) { | ||
| 416 | self.collisions += 1; | ||
| 417 | try self.list(); | ||
| 418 | } else { | ||
| 419 | self.level = .session_name; | ||
| 420 | self.closed = self.mode == .quick; | ||
| 421 | self.setNotice(if (self.mode == .quick) "Session names keep changing; try again" else result.reasonText()); | ||
| 422 | } | ||
| 423 | }, | ||
| 369 | else => { | 424 | else => { |
| 370 | self.level = if (creating) .session_name else .sessions; | 425 | self.level = if (creating) .session_name else .sessions; |
| 371 | if (!creating) self.session_count = 0; | 426 | if (!creating) self.session_count = 0; |
| 372 | if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText()); | 427 | 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; | ||
| 373 | }, | 429 | }, |
| 374 | } | 430 | } |
| 375 | return; | 431 | return; |
| 376 | } | 432 | } |
| 377 | fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void { | 433 | fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void { |
| 378 | const message = std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ if (created) "Session created" else "Creation outcome unknown for", self.input.items, suffix }) catch self.notice[0..]; | 434 | const outcome = if (created) "Session created" else "Creation outcome unknown for"; |
| 435 | const message = if (self.mode == .quick) | ||
| 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..] | ||
| 437 | else | ||
| 438 | std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ outcome, self.input.items, suffix }) catch self.notice[0..]; | ||
| 379 | self.notice_len = message.len; | 439 | self.notice_len = message.len; |
| 380 | } | 440 | } |
| 381 | fn setNotice(self: *Picker, text_: []const u8) void { | 441 | fn setNotice(self: *Picker, text_: []const u8) void { |
| @@ -426,3 +486,62 @@ test "picker validates fit before create and preserves remote outcome after orig | |||
| 426 | try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null); | 486 | try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null); |
| 427 | try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len); | 487 | try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len); |
| 428 | } | 488 | } |
| 489 | |||
| 490 | test "quick creation retries collisions but retains unknown outcomes and its original destination" { | ||
| 491 | const a = std.testing.allocator; | ||
| 492 | var rt = runtime.Runtime.init(a, .{}); | ||
| 493 | defer rt.deinit(); | ||
| 494 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 495 | const id = try rt.add(.{ .via = "cat" }, "origin", 1200, 900, metrics); | ||
| 496 | const neighbor = try rt.add(.{ .via = "cat" }, "neighbor", 1200, 900, metrics); | ||
| 497 | _ = rt.workspace.focus(id); | ||
| 498 | rt.workspace.arm(.stacked); | ||
| 499 | 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 }; | ||
| 501 | defer opening.arena.deinit(); | ||
| 502 | defer opening.input.deinit(a); | ||
| 503 | defer if (opening.job) |job| job.stop(); | ||
| 504 | try opening.includeTarget(rt.workspace.pane(id).?.identity.target); | ||
| 505 | var result: discovery.Result = .{ .ticket = opening.ticket, .phase = .sessions }; | ||
| 506 | const names = "0\n2\n"; | ||
| 507 | @memcpy(result.bytes[0..names.len], names); | ||
| 508 | result.len = names.len; | ||
| 509 | try opening.applyResult(result, false); | ||
| 510 | try std.testing.expectEqualStrings("1", opening.job.?.opts.operation.create.name); | ||
| 511 | opening.job.?.stop(); | ||
| 512 | opening.job = null; | ||
| 513 | try opening.applyResult(.{ .ticket = opening.ticket, .phase = .exists }, true); | ||
| 514 | try std.testing.expect(opening.job.?.opts.operation == .list and !opening.closed); | ||
| 515 | opening.job.?.stop(); | ||
| 516 | opening.job = null; | ||
| 517 | const updated = "0\n1\n2\n"; | ||
| 518 | @memcpy(result.bytes[0..updated.len], updated); | ||
| 519 | result.len = updated.len; | ||
| 520 | result.ticket = opening.ticket; | ||
| 521 | try opening.applyResult(result, false); | ||
| 522 | try std.testing.expectEqualStrings("3", opening.job.?.opts.operation.create.name); | ||
| 523 | opening.job.?.stop(); | ||
| 524 | opening.job = null; | ||
| 525 | try opening.applyResult(.{ .ticket = opening.ticket, .phase = .failed, .may_have_created = true }, true); | ||
| 526 | try std.testing.expect(opening.closed and opening.job == null and !opening.inserted); | ||
| 527 | try std.testing.expect(std.mem.indexOf(u8, opening.noticeText(), "outcome unknown for '3'") != null); | ||
| 528 | |||
| 529 | opening.closed = false; | ||
| 530 | rt.workspace.active_tab_id += 1; | ||
| 531 | try opening.applyResult(.{ .ticket = opening.ticket, .phase = .created, .may_have_created = true }, true); | ||
| 532 | try std.testing.expect(opening.closed and !opening.inserted); | ||
| 533 | try std.testing.expect(std.mem.indexOf(u8, opening.noticeText(), "Session created '3'") != null); | ||
| 534 | rt.workspace.active_tab_id = opening.origin_tab; | ||
| 535 | |||
| 536 | opening.closed = false; | ||
| 537 | _ = rt.workspace.focus(neighbor); | ||
| 538 | try opening.applyResult(.{ .ticket = opening.ticket, .phase = .created, .may_have_created = true }, true); | ||
| 539 | try std.testing.expect(opening.closed and opening.inserted); | ||
| 540 | try std.testing.expectEqual(neighbor, rt.workspace.tab().focus.?); | ||
| 541 | const flat = rt.workspace.layout(1200, 900, metrics); | ||
| 542 | try std.testing.expectEqual(@as(usize, 3), flat.len); | ||
| 543 | const inserted = flat.items()[1]; | ||
| 544 | try std.testing.expect(inserted.outer.y >= flat.get(id).?.outer.y + flat.get(id).?.outer.h); | ||
| 545 | try std.testing.expectEqualStrings("3", rt.workspace.pane(inserted.id).?.identity.session); | ||
| 546 | try std.testing.expect(rt.get(inserted.id).?.pump.opts.existing_only); | ||
| 547 | } | ||
test/native_journey.py
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ start from a lean fixture instead of each re-proving the same window setup. | |||
| 7 | """ | 7 | """ |
| 8 | import os | 8 | import os |
| 9 | import shlex | 9 | import shlex |
| 10 | import socket | ||
| 10 | import subprocess | 11 | import subprocess |
| 11 | import sys | 12 | import sys |
| 12 | import time | 13 | import time |
| @@ -137,6 +138,55 @@ def flood_while_responsive(rig, refs): | |||
| 137 | rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)') | 138 | rig.ok(f'local flood leaves QUIC pane responsive ({latency * 1000:.0f} ms, {p99} us frame p99)') |
| 138 | 139 | ||
| 139 | 140 | ||
| 141 | def quick_panes(rig, refs): | ||
| 142 | left, right, lower = list(refs) | ||
| 143 | # Returning to the remote after a local split must inherit the remote again; | ||
| 144 | # detached automatic sessions remain alive and must not be joined by accident. | ||
| 145 | for origin, direction in ((right, 'stacked'), (lower, 'beside'), (right, 'stacked')): | ||
| 146 | rig.focus(origin) | ||
| 147 | before = rig.state() | ||
| 148 | sock, _ = refs[origin] | ||
| 149 | names = sessions(sock) | ||
| 150 | other_sock = refs[left if origin == right else right][0] | ||
| 151 | other_names = sessions(other_sock) | ||
| 152 | rig.split(direction) | ||
| 153 | state = rig.wait_state(lambda s: len(s['panes']) == 4 and | ||
| 154 | all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 155 | require(state['picker'] is None and not state['command_mode'], | ||
| 156 | 'quick split required a picker or stayed in command mode') | ||
| 157 | pane = by_id(state)[state['focus']] | ||
| 158 | require(pane['id'] not in refs, 'quick split did not focus its new pane') | ||
| 159 | name = pane['label'].rsplit('#', 1)[1] | ||
| 160 | require(name.isdecimal() and name not in names and sessions(sock) == names | {name}, | ||
| 161 | 'quick split did not create a fresh numeric session on the focused daemon') | ||
| 162 | require(sessions(other_sock) == other_names, 'quick split created on the wrong daemon') | ||
| 163 | source = by_id(state)[origin] | ||
| 164 | require(pane['label'].rsplit('#', 1)[0] == source['label'].rsplit('#', 1)[0], | ||
| 165 | 'quick split lost its exact connection target') | ||
| 166 | axis, extent = ('y', 'h') if direction == 'stacked' else ('x', 'w') | ||
| 167 | require(pane['outer'][axis] >= source['outer'][axis] + source['outer'][extent], | ||
| 168 | 'quick split inserted on the wrong side') | ||
| 169 | rig.mark(pane['id'], sock, name, 'QUICK-' + str(pane['id'])) | ||
| 170 | rig.chord('d') | ||
| 171 | state = rig.wait_state(lambda s: len(s['panes']) == 3) | ||
| 172 | require(stable_layout(state) == stable_layout(before), 'detaching quick pane changed existing layout') | ||
| 173 | require(name in sessions(sock), 'detaching a quick pane ended its session') | ||
| 174 | |||
| 175 | # A split that cannot fit must fail before creating a remote shell. | ||
| 176 | rig.focus(right) | ||
| 177 | names = sessions(refs[right][0]) | ||
| 178 | rig.send('resize:8x8') | ||
| 179 | rig.wait_state(lambda s: (s['width'], s['height']) == (8, 8)) | ||
| 180 | rig.split('stacked') | ||
| 181 | state = rig.wait_state(lambda s: bool(s['notice'])) | ||
| 182 | require(state['picker'] is None and len(state['panes']) == 3 and state['pending'] is None, | ||
| 183 | 'undersized quick split changed the workspace') | ||
| 184 | require(sessions(refs[right][0]) == names, 'undersized quick split created a session') | ||
| 185 | rig.send('resize:960x600') | ||
| 186 | rig.wait_state(lambda s: (s['width'], s['height']) == (960, 600)) | ||
| 187 | rig.ok('quick splits inherit local/QUIC targets, auto-name fresh sessions, and reject undersized panes') | ||
| 188 | |||
| 189 | |||
| 140 | def raw_snapshot_and_terminal_peer(rig, refs): | 190 | def raw_snapshot_and_terminal_peer(rig, refs): |
| 141 | left = next(iter(refs)) | 191 | left = next(iter(refs)) |
| 142 | sock, name = refs[left] | 192 | sock, name = refs[left] |
| @@ -173,7 +223,23 @@ def independent_failures(rig, refs): | |||
| 173 | if sock == refs[right][0]) | 223 | if sock == refs[right][0]) |
| 174 | rig.stop_daemon(remote_sock, remote_proc) | 224 | rig.stop_daemon(remote_sock, remote_proc) |
| 175 | rig.wait_state(lambda s: by_id(s)[right]['phase'] != 'attached') | 225 | rig.wait_state(lambda s: by_id(s)[right]['phase'] != 'attached') |
| 176 | rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR') | 226 | # Keep the fixture port bound but silent: an unbound UDP port can fail |
| 227 | # immediately via ICMP, which would never exercise pending cancellation. | ||
| 228 | port = int(rig.targets[remote_sock].rsplit(':', 1)[1]) | ||
| 229 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as silent: | ||
| 230 | silent.bind(('127.0.0.1', port)) | ||
| 231 | rig.focus(right) | ||
| 232 | rig.split('stacked') | ||
| 233 | pending = rig.wait_state(lambda s: s['opening']) | ||
| 234 | require(pending['picker'] is None and 'Opening on' in pending['notice'], | ||
| 235 | 'pending remote opening hid the terminal behind a picker') | ||
| 236 | rig.mark(left, *refs[left], 'JOURNEY-SURVIVOR') | ||
| 237 | require(rig.state()['opening'], 'remote opening ended before the cancellation check') | ||
| 238 | started = time.monotonic() | ||
| 239 | rig.key('escape') | ||
| 240 | cancelled = rig.wait_state(lambda s: not s['opening']) | ||
| 241 | require(time.monotonic() - started < 1 and len(cancelled['panes']) == 3 and | ||
| 242 | cancelled['pending'] is None, 'cancelling the remote opening blocked or changed panes') | ||
| 177 | 243 | ||
| 178 | rig.focus(lower) | 244 | rig.focus(lower) |
| 179 | rig.shell('exit 7') | 245 | rig.shell('exit 7') |
| @@ -183,7 +249,7 @@ def independent_failures(rig, refs): | |||
| 183 | require(rig.gui.poll() is None, 'a failed transport or exited neighbour closed the window') | 249 | require(rig.gui.poll() is None, 'a failed transport or exited neighbour closed the window') |
| 184 | rig.quit() | 250 | rig.quit() |
| 185 | require('left' in sessions(refs[left][0]), 'final close ended the surviving local session') | 251 | require('left' in sessions(refs[left][0]), 'final close ended the surviving local session') |
| 186 | rig.ok('remote loss and shell exit stay confined to their panes') | 252 | rig.ok('remote opening stays responsive and cancels promptly; transport loss and shell exit stay confined') |
| 187 | 253 | ||
| 188 | missing = str(rig.root / 'missing.sock') | 254 | missing = str(rig.root / 'missing.sock') |
| 189 | rig.launch_gui(['--sock', missing, '--session', 'offline'], 'gui-journey-unavailable', attached=False) | 255 | rig.launch_gui(['--sock', missing, '--session', 'offline'], 'gui-journey-unavailable', attached=False) |
| @@ -203,6 +269,7 @@ def main(): | |||
| 203 | try: | 269 | try: |
| 204 | release_binary(rig) | 270 | release_binary(rig) |
| 205 | refs = basic_workspace(rig) | 271 | refs = basic_workspace(rig) |
| 272 | quick_panes(rig, refs) | ||
| 206 | state = rig.state() | 273 | state = rig.state() |
| 207 | identities = [(p['id'], p['generation']) for p in state['panes']] | 274 | identities = [(p['id'], p['generation']) for p in state['panes']] |
| 208 | drag_and_keys(rig, identities, refs) | 275 | drag_and_keys(rig, identities, refs) |
test/native_lifecycle.py
| Old | New | ||
|---|---|---|---|
| @@ -182,7 +182,7 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 182 | rig.new_session('left') | 182 | rig.new_session('left') |
| 183 | rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') | 183 | rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached') |
| 184 | if picker_checks: | 184 | if picker_checks: |
| 185 | rig.chord('b') | 185 | rig.open_picker('beside') |
| 186 | armed = rig.picker('hosts') | 186 | armed = rig.picker('hosts') |
| 187 | require(armed['pending']['direction'] == 'beside', | 187 | require(armed['pending']['direction'] == 'beside', |
| 188 | 'b did not arm a split beside the focused pane') | 188 | 'b did not arm a split beside the focused pane') |
| @@ -201,7 +201,7 @@ def start_workspace(rig, *, quic=False, picker_checks=False): | |||
| 201 | rig.new_session('right') | 201 | rig.new_session('right') |
| 202 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) | 202 | rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes'])) |
| 203 | if picker_checks: | 203 | if picker_checks: |
| 204 | rig.chord('v') | 204 | rig.open_picker('stacked') |
| 205 | armed = rig.picker('hosts') | 205 | armed = rig.picker('hosts') |
| 206 | require(armed['pending']['direction'] == 'stacked', | 206 | require(armed['pending']['direction'] == 'stacked', |
| 207 | 'v did not arm a split below the focused pane') | 207 | 'v did not arm a split below the focused pane') |
test/native_picker.py
| Old | New | ||
|---|---|---|---|
| @@ -221,7 +221,7 @@ def add_host(rig): | |||
| 221 | rig.catalogue([first_target]) | 221 | rig.catalogue([first_target]) |
| 222 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") | 222 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host") |
| 223 | before = rig.pixels() | 223 | before = rig.pixels() |
| 224 | rig.split("beside") | 224 | rig.open_picker("beside") |
| 225 | state = rig.picker("hosts") | 225 | state = rig.picker("hosts") |
| 226 | rect = state["picker"]["rect"] | 226 | rect = state["picker"]["rect"] |
| 227 | painted = rig.pixels() | 227 | painted = rig.pixels() |
| @@ -259,7 +259,7 @@ def slow_and_legacy(rig): | |||
| 259 | slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path | 259 | slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path |
| 260 | rig.catalogue([first_target, slow_target, legacy_target]) | 260 | rig.catalogue([first_target, slow_target, legacy_target]) |
| 261 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") | 261 | rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker") |
| 262 | rig.split("beside") | 262 | rig.open_picker("beside") |
| 263 | rig.picker("hosts") | 263 | rig.picker("hosts") |
| 264 | rig.choose(slow_target) | 264 | rig.choose(slow_target) |
| 265 | eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") | 265 | eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried") |
test/native_tiling.py
| Old | New | ||
|---|---|---|---|
| @@ -135,6 +135,12 @@ class Rig: | |||
| 135 | require(key is not None, "unknown split direction: " + str(direction)) | 135 | require(key is not None, "unknown split direction: " + str(direction)) |
| 136 | self.chord(key) | 136 | self.chord(key) |
| 137 | 137 | ||
| 138 | def open_picker(self, direction=None): | ||
| 139 | self.chord("enter") | ||
| 140 | self.picker("hosts") | ||
| 141 | if direction: | ||
| 142 | self.key(SPLIT_BINDINGS[direction]) | ||
| 143 | |||
| 138 | def shell(self, command): | 144 | def shell(self, command): |
| 139 | self.send("text:" + command, "key:enter") | 145 | self.send("text:" + command, "key:enter") |
| 140 | 146 | ||
| @@ -181,11 +187,7 @@ class Rig: | |||
| 181 | self.key("enter") | 187 | self.key("enter") |
| 182 | 188 | ||
| 183 | def host(self, spelling, direction=None): | 189 | def host(self, spelling, direction=None): |
| 184 | if direction: | 190 | self.open_picker(direction) |
| 185 | self.split(direction) | ||
| 186 | else: | ||
| 187 | self.chord("enter") | ||
| 188 | self.picker("hosts") | ||
| 189 | self.choose(spelling) | 191 | self.choose(spelling) |
| 190 | self.picker("sessions") | 192 | self.picker("sessions") |
| 191 | 193 | ||
| @@ -308,10 +310,10 @@ def normal_scenario(rig, direction): | |||
| 308 | require(result.returncode == 2, "retired staged-target arguments were accepted") | 310 | require(result.returncode == 2, "retired staged-target arguments were accepted") |
| 309 | rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") | 311 | rig.ok("retired staged-target flags are rejected; picker supplies insertion targets") |
| 310 | before = rig.start_gui(first, second, "gui-" + direction) | 312 | before = rig.start_gui(first, second, "gui-" + direction) |
| 311 | rig.split(direction) | 313 | rig.open_picker(direction) |
| 312 | armed = rig.picker("hosts") | 314 | armed = rig.picker("hosts") |
| 313 | require(armed["pending"]["direction"] == direction, | 315 | require(armed["pending"]["direction"] == direction, |
| 314 | "split chord opened picker with the wrong direction") | 316 | "picker has the wrong split direction") |
| 315 | require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], | 317 | require(len(armed["panes"]) == 1 and armed["panes"][0]["outer"] == before["panes"][0]["outer"], |
| 316 | "arming split changed geometry") | 318 | "arming split changed geometry") |
| 317 | stats = rig.command("d", "stats", "--sock", second).stdout | 319 | stats = rig.command("d", "stats", "--sock", second).stdout |
| @@ -328,7 +330,7 @@ def normal_scenario(rig, direction): | |||
| 328 | "closing host picker changed panes or forgot its armed split") | 330 | "closing host picker changed panes or forgot its armed split") |
| 329 | rig.key("escape") | 331 | rig.key("escape") |
| 330 | rig.wait_state(lambda s: s["pending"] is None) | 332 | rig.wait_state(lambda s: s["pending"] is None) |
| 331 | rig.split(direction) | 333 | rig.open_picker(direction) |
| 332 | rig.picker("hosts") | 334 | rig.picker("hosts") |
| 333 | rig.choose(rig.targets.get(second, "--sock " + second)) | 335 | rig.choose(rig.targets.get(second, "--sock " + second)) |
| 334 | rig.picker("sessions") | 336 | rig.picker("sessions") |