a73x

src/gui/interaction.zig

Ref:   Size: 53.1 KiB   History

//! Native interaction policy, independent of window APIs and rendering.
//! The frame adapter supplies logical keys, physical pointer coordinates and
//! measured geometry. This controller owns modes, menu lifetimes, input release
//! ownership and asynchronous actions bound to attachment identities.
const std = @import("std");
const client = @import("client");
const app_input = @import("input");
const model = @import("workspace.zig");
const runtime = @import("runtime.zig");
const picker_mod = @import("picker.zig");
const persistence = @import("persistence.zig");

pub const Key = enum { v, b, f, r, d, x, p, escape, enter, keypad_enter, up, k, down, j, left, h, right, l, backspace, other };
/// code preserves platform key identity so aliases release independently.
/// kind describes GUI meaning; terminal carries the platform's input translation.
pub const KeyDown = struct {
    code: u32,
    kind: Key = .other,
    prefix: bool = false,
    modified: bool = false,
    repeat: bool = false,
    terminal: ?app_input.Event = null,
};
pub const PendingEnd = struct { key: model.Attachment, request: u64 };
pub const Wheel = struct {
    fraction: f32 = 0,
    /// Convert native wheel lines into whole terminal notches, retaining input.
    pub fn notches(self: *Wheel, delta: f32, flipped: bool) i32 {
        if (!std.math.isFinite(delta)) return 0;
        self.fraction += std.math.clamp(if (flipped) -delta else delta, -1024, 1024);
        const whole = @trunc(self.fraction);
        self.fraction -= whole;
        return @intFromFloat(whole);
    }
};

pub const Recovery = struct {
    pub const View = struct {
        rect: model.Rect,
        first: usize,
        shown: usize,
        row_height: u16,
        pub fn rowRect(self: View, index: usize) model.Rect {
            if (index < self.first or index >= self.first + self.shown) return .{};
            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);
        }
    };
    kind: enum { recovery, force_end },
    key: model.Attachment,
    selected: usize = 0,
    notice: [1024]u8 = @splat(0),
    notice_len: usize = 0,
    pub fn count(self: Recovery) usize {
        return switch (self.kind) {
            .recovery => 3,
            .force_end => 2,
        };
    }
    pub fn label(self: Recovery, index: usize) []const u8 {
        return switch (self.kind) {
            .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index],
            .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index],
        };
    }
    pub fn title(self: Recovery) []const u8 {
        return switch (self.kind) {
            .recovery => "Pane actions",
            .force_end => "Other clients are attached",
        };
    }
    pub fn setNotice(self: *Recovery, text: []const u8) void {
        self.notice_len = @min(text.len, self.notice.len);
        @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]);
    }
    pub fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) View {
        const w = @min(width, @as(u32, metrics.cell_w) * 74);
        const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4)));
        return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h };
    }
};
pub const Controller = struct {
    rt: *runtime.Runtime,
    opening: ?*picker_mod.Picker = null,
    next_request: u64 = 1,
    key_path: ?[]const u8 = null,
    local_target: ?client.Target = null,
    store: ?*persistence.Store = null,
    intent_dirty: bool = false,
    save_notice: [256]u8 = @splat(0),
    save_notice_len: usize = 0,
    recovery: ?Recovery = null,
    pending_end: ?PendingEnd = null,
    notice_owned: [1024]u8 = @splat(0),
    layout: model.Layout = .{},
    metrics: model.Metrics,
    fb_w: i32,
    fb_h: i32,
    dirty: bool = true,
    suppress_text: bool = false,
    command_mode: bool = false,
    resize_mode: bool = false,
    modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty,
    drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null,
    selection_drag: client.selection.Drag = .{},
    app_drag: ?struct { key: model.Attachment, token: client.session_pump.MouseToken, button: u8 } = null,
    local_held: bool = false,
    wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}),
    selection_key: ?model.Attachment = null,
    selection_request: u32 = 0,
    selection_gesture: u32 = 0,
    selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 },
    consumed_key: ?u32 = null,
    notice: []const u8 = "",

    wake_ctx: ?*anyopaque = null,
    wake: ?*const fn (?*anyopaque, client.discovery.Ticket) void = null,
    pub fn deinit(self: *Controller) void {
        if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{});
        self.cancelDrag();
        self.clearSelection();
        self.modal_held.deinit(self.rt.alloc);
        if (self.opening) |opening| {
            opening.cancel();
            if (opening.mode == .quick) std.debug.print("muxg: {s}\n", .{opening.noticeText()});
            opening.deinit();
            self.opening = null;
        }
    }
    /// The modal view of the active request; quick opening leaves input live.
    pub fn modalPicker(self: *const Controller) ?*picker_mod.Picker {
        const opening = self.opening orelse return null;
        return if (opening.mode == .quick) null else opening;
    }
    pub fn hasPointerCapture(self: *const Controller) bool {
        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);
    }
    pub fn cancelMouse(self: *Controller) void {
        if (self.app_drag) |app| {
            if (self.rt.accepts(app.key)) self.rt.get(app.key.pane).?.pump.cancelMouse(app.token);
            self.app_drag = null;
        }
        self.local_held = false;
    }
    pub fn clearSelection(self: *Controller) void {
        if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection();
        self.selection_drag.clear();
        self.selection_key = null;
        self.selection_gesture = 0;
        self.dirty = true;
    }
    fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit {
        if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return null;
        const id = self.layout.hit(x, y) orelse return null;
        const p = self.layout.get(id) orelse return null;
        if (x < p.content.x or y < p.content.y or x >= p.content.x + p.content.w or y >= p.content.y + p.content.h) return null;
        const live = self.rt.get(id) orelse return null;
        const local_row: u32 = (y - p.content.y) / self.metrics.cell_h;
        const col: u16 = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x - p.content.x) / self.metrics.cell_w));
        if (local_row >= live.snapshot.rows or col >= live.snapshot.cols) return null;
        const actual_col = if (live.snapshot.row(@intCast(local_row)).cells[col].wide == .spacer_tail and col > 0) col - 1 else col;
        return .{ .tile = @intCast(id), .row = live.view_origin + local_row, .col = actual_col };
    }
    fn cellFor(self: *Controller, x: u32, y: u32) client.selection.Cell {
        const h = self.hit(x, y) orelse return self.selection_drag.at;
        const p = self.layout.get(@intCast(h.tile)) orelse return self.selection_drag.at;
        return .{ .row = @intCast((y -| p.content.y) / self.metrics.cell_h), .col = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x -| p.content.x) / self.metrics.cell_w)) };
    }
    pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span {
        if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) {
            if (self.selection_gesture != 0 and !self.selection_drag.buttonHeld()) {
                const position = self.rt.get(key.pane).?.snapshot_follow orelse return null;
                if (position.id != self.selection_gesture or position.status != .ok) return null;
                const range: client.selection.Range = .{
                    .from = .{ .tile = tile, .row = position.anchor.row, .col = position.anchor.col },
                    .to = .{ .tile = tile, .row = position.active.row, .col = position.active.col },
                };
                return range.span(tile, row, cols);
            }
            return (self.selection_drag.range() orelse return null).span(tile, row, cols);
        };
        return null;
    }
    pub fn poll(self: *Controller, now: i64) bool {
        const changed = self.rt.poll(now);
        if (self.app_drag) |app| {
            if (!self.rt.accepts(app.key) or !self.rt.get(app.key.pane).?.pump.mouseFresh(app.token)) self.cancelMouse();
        }
        if (self.selection_key) |key| {
            const live = self.rt.get(key.pane) orelse {
                self.clearSelection();
                return true;
            };
            const fresh = if (self.selection_drag.buttonHeld())
                live.pump.selectionFresh(self.selection_version)
            else if (self.selection_gesture != 0)
                live.pump.selectionAlive(self.selection_gesture, self.selection_version)
            else
                live.pump.selectionFresh(self.selection_version);
            if (!self.rt.accepts(key) or !fresh) {
                self.clearSelection();
                return true;
            }
        }
        return changed;
    }
    pub fn takeSelectionText(self: *Controller) ?[]u8 {
        const key = self.selection_key orelse return null;
        const live = self.rt.get(key.pane) orelse return null;
        if (!self.rt.accepts(key)) return null;
        const result = live.pump.takeSelection() orelse return null;
        if (result.id != self.selection_request or result.gesture != self.selection_gesture or result.status != .ok) {
            self.rt.alloc.free(result.text);
            if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable");
            return null;
        }
        return result.text;
    }
    pub fn command(self: *Controller, key: Key) !void {
        self.cancelDrag();
        self.clearSelection();
        self.command_mode = false;
        self.notice = "";
        const ws = &self.rt.workspace;
        if (keyDirection(key)) |direction| {
            const before = ws.tab().focus;
            ws.moveFocus(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
            if (before != ws.tab().focus) try self.relayout();
            self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
        } else switch (key) {
            .v, .b, .enter, .keypad_enter => {
                if (self.opening != null) {
                    self.dirty = true;
                    return;
                }
                if (key == .v or key == .b) {
                    ws.arm(if (key == .v) .stacked else .beside);
                    self.open(.quick) catch |err| {
                        ws.cancel();
                        self.setNotice(@errorName(err));
                    };
                } else self.open(.insert) catch |err| self.setNotice(@errorName(err));
            },
            .f => {
                ws.tab().fullscreen = !ws.tab().fullscreen and ws.tab().focus != null;
                try self.relayout();
            },
            .r => {
                ws.tab().fullscreen = false;
                try self.relayout();
                self.resize_mode = true;
            },
            .d => if (ws.tab().focus) |id| try self.detach(id),
            .x => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
            .p => if (ws.tab().focus) |id| {
                self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key };
                self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
            },
            .escape => {
                if (self.opening) |opening| opening.cancel();
                try self.finishOpening();
                ws.cancel();
            },
            else => {},
        }
        self.dirty = true;
    }

    pub fn requestReady(self: *const Controller) bool {
        const opening = self.opening orelse return false;
        const job = opening.job orelse return false;
        return job.done.load(.acquire);
    }
    /// Geometry is refreshed by the frame before completed requests insert.
    pub fn pollOpening(self: *Controller) !void {
        const opening = self.opening orelse return;
        self.dirty = (try opening.poll()) or self.dirty;
        try self.finishOpening();
    }
    fn finishOpening(self: *Controller) !void {
        const opening = self.opening orelse return;
        if (!opening.closed) return;
        const inserted = opening.inserted;
        if (inserted) {
            self.notice = "";
        } else {
            // A dismiss may follow an uncertain create. Keep that outcome
            // visible after the presentation is gone, never imply a retry.
            self.setNotice(opening.noticeText());
            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();
        }
        opening.deinit();
        self.opening = null;
        if (inserted) {
            self.cancelDrag();
            self.clearSelection();
            try self.relayout();
            self.intent_dirty = true;
        }
        self.dirty = true;
    }

    pub fn open(self: *Controller, mode: picker_mod.Picker.Mode) !void {
        if (self.opening != null) {
            self.setNotice("A pane is opening; Esc cancels it");
            return;
        }
        self.cancelDrag();
        self.clearSelection();
        self.recovery = null;
        self.notice = "";
        const actual_mode = if (mode == .quick and self.rt.workspace.tab().focus == null) .insert else mode;
        self.opening = blk: {
            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);
            errdefer opening.deinit();
            if (opening.origin == null) if (self.local_target) |target| try opening.includeTarget(target);
            break :blk opening;
        };
        try self.finishOpening();
        self.dirty = true;
    }
    pub fn setNotice(self: *Controller, text: []const u8) void {
        const n = @min(text.len, self.notice_owned.len);
        @memcpy(self.notice_owned[0..n], text[0..n]);
        self.notice = self.notice_owned[0..n];
        self.dirty = true;
    }
    pub fn detach(self: *Controller, id: model.PaneId) !void {
        self.cancelDrag();
        self.resize_mode = false;
        if (self.opening) |opening| if (opening.mode == .quick and opening.origin.?.pane == id) {
            opening.cancel();
            try self.finishOpening();
        };
        if (self.pending_end) |pending| if (pending.key.pane == id) {
            self.pending_end = null;
            self.setNotice("Pane detached; the pending End outcome is unknown");
        };
        self.recovery = null;
        self.rt.remove(id);
        try self.relayout();
        self.intent_dirty = true;
    }
    pub fn beginEnd(self: *Controller, key: model.Attachment, force: bool) !void {
        if (!self.rt.accepts(key)) return;
        if (self.pending_end != null) {
            self.setNotice("An End request is still pending");
            return;
        }
        const request = self.next_request;
        self.next_request += 1;
        self.rt.get(key.pane).?.pump.say(.{ .end = .{ .request = request, .force = force } }) catch |err| {
            self.setNotice(@errorName(err));
            self.recovery = null;
            return;
        };
        self.pending_end = .{ .key = key, .request = request };
        self.dirty = true;
    }
    pub fn pollEnd(self: *Controller) !void {
        const pending = self.pending_end orelse return;
        if (!self.rt.accepts(pending.key)) {
            self.pending_end = null;
            self.setNotice("Pane changed; the earlier End outcome is unknown");
            return;
        }
        const result = self.rt.get(pending.key.pane).?.status.ending;
        if (result.request != pending.request or result.phase == .pending) return;
        self.pending_end = null;
        switch (result.phase) {
            .accepted => try self.detach(pending.key.pane),
            .refused => {
                self.setNotice(result.reasonText());
                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) {
                    self.recovery = .{ .kind = .force_end, .key = pending.key };
                    self.recovery.?.setNotice(result.reasonText());
                }
            },
            .unknown => self.setNotice(result.reasonText()),
            else => {},
        }
        self.dirty = true;
    }
    pub fn recoveryAction(self: *Controller) !void {
        const menu = self.recovery orelse return;
        self.recovery = null;
        self.dirty = true;
        if (!self.rt.accepts(menu.key)) {
            self.setNotice("The pane changed");
            return;
        }
        switch (menu.kind) {
            .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true),
            .recovery => switch (menu.selected) {
                0, 1 => {
                    if (self.pending_end != null) {
                        self.setNotice("Wait for the pending End outcome before replacing the attachment");
                        return;
                    }
                    if (menu.selected == 0) {
                        self.rt.retry(menu.key.pane, self.layout.get(menu.key.pane).?) catch |err| {
                            self.setNotice(@errorName(err));
                            return;
                        };
                        self.intent_dirty = true;
                    } else {
                        _ = self.rt.workspace.focus(menu.key.pane);
                        try self.open(.replace);
                    }
                },
                2 => try self.detach(menu.key.pane),
                else => unreachable,
            },
        }
    }
    pub fn saveIntent(self: *Controller) void {
        if (!self.intent_dirty or self.drag != null) return;
        self.intent_dirty = false;
        const store = self.store orelse return;
        store.save(&self.rt.workspace) catch |err| {
            const text = std.fmt.bufPrint(&self.save_notice, "Workspace not saved: {s}", .{@errorName(err)}) catch unreachable;
            self.save_notice_len = text.len;
            self.dirty = true;
            return;
        };
        self.save_notice_len = 0;
    }
    pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void {
        self.cancelDrag();
        self.clearSelection();
        if (self.recovery) |menu| {
            const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
            for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) {
                self.recovery.?.selected = i;
                try self.recoveryAction();
                break;
            };
        } else if (self.modalPicker()) |picker| {
            picker.pointerDown(x, y);
            try self.finishOpening();
        } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
            const d = self.layout.divider(id).?;
            const position = if (d.direction == .beside) x else y;
            self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() };
        } else if (!self.resize_mode) {
            if (self.layout.hit(x, y)) |id| {
                self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id;
                _ = self.rt.workspace.focus(id);
                const point = self.hit(x, y);
                self.selection_drag.press(.{ .row = if (point) |h| @intCast(h.row - self.rt.get(id).?.view_origin) else 0, .col = if (point) |h| h.col else 0 }, point);
                if (point) |h| {
                    const live = self.rt.get(h.tile).?;
                    self.selection_key = live.key;
                    self.selection_version = live.snapshot_version;
                }
            } else if (self.layout.len == 0) try self.open(.insert);
        }
        self.dirty = true;
    }

    pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: app_input.Mods) !void {
        if (self.hasPointerCapture() or button > 2 or self.command_mode) return;
        if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) {
            if (button == 0) try self.pointerDown(x, y, grab_x, grab_y);
            return;
        }
        if (self.layout.hit(x, y)) |id| {
            const p = self.layout.get(id).?;
            if (p.content.contains(x, y)) if (self.rt.get(id)) |live| {
                if (!mods.shift) if (live.pump.mouseToken()) |token| if (token.modes.appMouse()) {
                    const event = self.mouseAt(live.key, token, .press, button, x, y, mods) orelse return;
                    try live.pump.say(.{ .mouse = event });
                    self.clearSelection();
                    self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id;
                    _ = self.rt.workspace.focus(id);
                    self.app_drag = .{ .key = live.key, .token = token, .button = button };
                    self.dirty = true;
                    return;
                };
            };
        }
        if (button != 0) return;
        try self.pointerDown(x, y, grab_x, grab_y);
        self.local_held = self.selection_drag.buttonHeld();
    }

    fn mouseAt(self: *Controller, key: model.Attachment, token: client.session_pump.MouseToken, kind: anytype, button: u8, x: u32, y: u32, mods: app_input.Mods) ?client.session_pump.Mouse {
        const p = self.layout.get(key.pane) orelse return null;
        if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0 or p.cols == 0 or p.rows == 0 or p.content.w == 0 or p.content.h == 0) return null;
        const px = std.math.clamp(x, p.content.x, p.content.x +| p.content.w -| 1);
        const py = std.math.clamp(y, p.content.y, p.content.y +| p.content.h -| 1);
        return .{ .token = token, .kind = kind, .button = button, .col = @intCast(@min(@as(u32, p.cols - 1), (px - p.content.x) / self.metrics.cell_w)), .row = @intCast(@min(@as(u32, p.rows - 1), (py - p.content.y) / self.metrics.cell_h)), .pixel_x = px - p.content.x, .pixel_y = py - p.content.y, .mods = mods };
    }

    pub fn mouseMove(self: *Controller, x: i64, y: i64, mods: app_input.Mods) !void {
        if (self.app_drag) |app| {
            if (!self.rt.accepts(app.key)) return self.cancelMouse();
            const live = self.rt.get(app.key.pane) orelse return self.cancelMouse();
            if (!live.pump.mouseFresh(app.token)) return self.cancelMouse();
            const event = self.mouseAt(app.key, app.token, .motion, app.button, @intCast(@max(x, 0)), @intCast(@max(y, 0)), mods) orelse return;
            try live.pump.say(.{ .mouse = event });
            return;
        }
        if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y);
        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;
        const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return;
        if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return;
        const p = self.layout.get(id) orelse return;
        if (!p.content.contains(@intCast(x), @intCast(y))) return;
        const live = self.rt.get(id) orelse return;
        const token = live.pump.mouseToken() orelse return;
        if (!token.modes.mouse_any) return;
        if (self.mouseAt(live.key, token, .motion, 3, @intCast(x), @intCast(y), mods)) |event| try live.pump.say(.{ .mouse = event });
    }

    pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: app_input.Mods) !void {
        if (self.modalPicker()) |picker| {
            if (button != 0) return;
            try picker.pointerUp(x, y);
            try self.finishOpening();
            self.dirty = true;
            return;
        }
        if (self.app_drag) |app| {
            if (button != app.button) return;
            if (!self.rt.accepts(app.key)) return self.cancelMouse();
            const live = self.rt.get(app.key.pane) orelse return self.cancelMouse();
            if (live.pump.mouseFresh(app.token)) {
                if (self.mouseAt(app.key, app.token, .release, app.button, x, y, mods)) |event| try live.pump.say(.{ .mouse = event });
            }
            self.app_drag = null;
            return;
        }
        if ((self.local_held or self.drag != null or self.selection_drag.buttonHeld()) and button == 0) {
            self.local_held = false;
            return self.pointerUp(x, y);
        }
    }

    pub fn pointerUp(self: *Controller, x: u32, y: u32) !void {
        if (self.drag != null) {
            self.cancelDrag();
            return;
        }
        const id = self.selection_key orelse return;
        const live = self.rt.get(id.pane) orelse return self.clearSelection();
        if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection();
        const point = self.hit(x, y);
        const cell = self.cellFor(x, y);
        self.selection_drag.motion(cell, point);
        switch (self.selection_drag.release()) {
            .click => {},
            .selection => |range| {
                // A release always starts a new daemon tracker, even when a
                // copy was requested while the button was held.
                self.selection_gesture = 0;
                try self.queueSelection(live, range);
            },
            .nothing => self.clearSelection(),
        }
        self.dirty = true;
    }
    fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void {
        self.selection_request +%= 1;
        if (self.selection_request == 0) self.selection_request = 1;
        if (self.selection_gesture == 0) self.selection_gesture = self.selection_request;
        try self.rt.requestSelection(live.key, self.selection_request, self.selection_gesture, range, self.selection_version);
    }
    pub fn copySelection(self: *Controller) !void {
        const key = self.selection_key orelse return;
        if (!self.rt.accepts(key)) return self.clearSelection();
        const live = self.rt.get(key.pane) orelse return;
        const range = self.selection_drag.range() orelse return;
        const held = self.selection_drag.buttonHeld();
        const valid = if (held)
            live.pump.selectionFresh(self.selection_version)
        else
            live.pump.selectionAlive(self.selection_gesture, self.selection_version);
        if (!valid) return self.clearSelection();
        if (held) self.selection_gesture = 0;
        try self.queueSelection(live, range);
    }
    pub fn copyShortcut(self: *Controller, key: u32) !void {
        self.consumeShortcut(key);
        try self.copySelection();
    }
    pub fn consumeShortcut(self: *Controller, key: u32) void {
        self.consumed_key = key;
        self.suppress_text = true;
    }
    pub fn pointerMove(self: *Controller, x: i64, y: i64) !void {
        if (self.drag == null and self.selection_drag.on() != null) {
            if (x < 0 or y < 0) {
                self.selection_drag.motion(self.selection_drag.at, null);
                self.dirty = true;
                return;
            }
            const px: u32 = @intCast(@max(x, 0));
            const py: u32 = @intCast(@max(y, 0));
            const point = self.hit(px, py);
            self.selection_drag.motion(self.cellFor(px, py), point);
            self.dirty = true;
            return;
        }
        const drag = self.drag orelse return;
        if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag();
        const d = self.layout.divider(drag.id) orelse return self.cancelDrag();
        const position = (if (d.direction == .beside) x else y) - drag.offset;
        if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
            self.drag.?.changed = true;
            try self.relayout();
        }
    }
    /// Semantic wheel entry point. Transport routing is deliberately deferred
    /// until the pump has sampled the pane's current terminal modes.
    pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: app_input.Mods) !void {
        if (self.modalPicker() != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return;
        const id = self.layout.hit(x, y) orelse return;
        const placement = self.layout.get(id) orelse return;
        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;
        const live = self.rt.get(id) orelse return;
        const slot = found: {
            for (&self.wheel_remainder) |*candidate| {
                if (candidate.key != null and candidate.key.?.pane == live.key.pane) break :found candidate;
            }
            for (&self.wheel_remainder) |*candidate| {
                if (candidate.key == null or self.rt.get(candidate.key.?.pane) == null) break :found candidate;
            }
            return;
        };
        if (slot.key == null or !std.meta.eql(slot.key.?, live.key)) {
            slot.* = .{ .key = live.key, .wheel = .{} };
        }
        const notches = slot.wheel.notches(delta, flipped);
        if (notches == 0) return;
        try self.rt.wheel(live.key, .{
            .notches = notches,
            .col = @intCast(@min((x - placement.content.x) / self.metrics.cell_w, placement.cols - 1)),
            .row = @intCast(@min((y - placement.content.y) / self.metrics.cell_h, placement.rows - 1)),
            .pixel_x = x - placement.content.x,
            .pixel_y = y - placement.content.y,
            .mods = mods,
        });
    }
    pub fn cancelDrag(self: *Controller) void {
        self.cancelMouse();
        if (self.drag == null) return;
        self.intent_dirty = self.intent_dirty or self.drag.?.changed;
        self.drag = null;
    }
    pub fn sendKey(self: *Controller, key: app_input.Event) !void {
        self.clearSelection();
        var buf: [app_input.max_seq_len]u8 = undefined;
        const bytes = app_input.encode(key, &buf);
        if (bytes.len != 0) try self.rt.input(bytes);
    }
    pub fn relayout(self: *Controller) !void {
        self.layout = self.rt.workspace.layout(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
        try self.rt.resize(&self.layout);
        self.dirty = true;
    }
    pub fn textInput(self: *Controller, text: []const u8) !void {
        if (!self.suppress_text) {
            if (self.modalPicker()) |picker| {
                try picker.text(text);
                self.dirty = true;
            } else if (!self.command_mode and !self.resize_mode and self.recovery == null) {
                self.clearSelection();
                try self.rt.input(text);
            }
        }
        self.suppress_text = false;
    }
    /// The frame owns clipboard access; the controller owns the current text
    /// destination and keeps the physical shortcut from leaking into it.
    pub fn pasteShortcut(self: *Controller, key: u32, text: []const u8) !void {
        self.consumeShortcut(key);
        if (text.len == 0) return;
        if (self.modalPicker()) |picker| {
            try picker.text(text);
            self.dirty = true;
        } else if (!self.command_mode and !self.resize_mode and self.recovery == null) {
            self.clearSelection();
            try self.rt.paste(text);
        }
    }
    pub fn keyUp(self: *Controller, code: u32) void {
        _ = self.modal_held.remove(code);
        if (self.consumed_key == code) self.consumed_key = null;
        self.suppress_text = false;
    }
    pub fn focusLost(self: *Controller) void {
        self.suppress_text = false;
        self.command_mode = false;
        self.resize_mode = false;
        self.modal_held.clearRetainingCapacity();
        if (self.modalPicker()) |picker| picker.cancelPress();
        self.cancelDrag();
        self.clearSelection();
        self.consumed_key = null;
        self.dirty = true;
    }
    pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void {
        if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) {
            self.cancelDrag();
            self.clearSelection();
            if (self.modalPicker()) |picker| picker.cancelPress();
        }
        self.fb_w = w;
        self.fb_h = h;
        self.metrics = metrics;
        if (self.opening) |opening| {
            opening.width = @intCast(@max(w, 0));
            opening.height = @intCast(@max(h, 0));
            opening.metrics = metrics;
        }
        try self.relayout();
    }
    pub fn keyDown(self: *Controller, input: KeyDown) !void {
        std.debug.assert(self.modalPicker() == null or self.recovery == null);
        self.suppress_text = false;
        const key = input.code;
        const kind = input.kind;
        if (!self.resize_mode and self.recovery == null and self.modalPicker() == null and self.modal_held.contains(key)) {
            self.suppress_text = true;
            return;
        }
        if (self.consumed_key == key) {
            self.suppress_text = true;
            return;
        }
        if (self.recovery) |*menu| {
            try self.modal_held.put(self.rt.alloc, key, {});
            self.suppress_text = true;
            if (menuKey(kind, false)) |mapped| switch (mapped) {
                .up => menu.selected -|= 1,
                .down => menu.selected = @min(menu.selected + 1, menu.count() - 1),
                .escape => {
                    self.consumed_key = key;
                    self.recovery = null;
                },
                .enter => {
                    self.consumed_key = key;
                    try self.recoveryAction();
                },
                .backspace => {},
            };
            self.dirty = true;
            return;
        }
        if (self.resize_mode) {
            self.cancelDrag();
            try self.modal_held.put(self.rt.alloc, key, {});
            self.suppress_text = true;
            if (kind == .escape or kind == .enter or kind == .keypad_enter) {
                self.resize_mode = false;
                self.consumed_key = key;
                self.notice = "";
            } else if (keyDirection(kind)) |direction| {
                if (!self.layout.fits) {
                    self.notice = "Window too small to resize";
                } else {
                    self.notice = "";
                    if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
                        try self.relayout();
                        self.intent_dirty = true;
                    }
                }
            }
            self.dirty = true;
            return;
        }
        if (self.modalPicker()) |picker| {
            try self.modal_held.put(self.rt.alloc, key, {});
            if (!input.modified and (kind == .v or kind == .b) and picker.mode == .insert and (picker.level == .hosts or picker.level == .sessions)) {
                picker.setDirection(if (kind == .v) .stacked else .beside);
                self.suppress_text = true;
                self.dirty = true;
                return;
            }
            const mapped = menuKey(kind, picker.level != .hosts and picker.level != .sessions);
            if (mapped) |k| {
                self.suppress_text = true;
                if (k == .enter or k == .escape) self.consumed_key = key;
                try picker.key(k);
            } else if (input.modified) self.suppress_text = true;
            self.dirty = true;
            try self.finishOpening();
            return;
        }
        if (input.prefix) {
            self.cancelDrag();
            self.suppress_text = true;
            if (!input.repeat) {
                if (self.command_mode) {
                    self.command_mode = false;
                    try self.sendKey(input.terminal.?);
                } else self.command_mode = true;
                self.notice = "";
                self.dirty = true;
            }
        } else if (self.command_mode) {
            self.consumed_key = key;
            self.suppress_text = true;
            try self.command(kind);
        } else if (kind == .escape and self.opening != null) {
            self.consumeShortcut(key);
            self.opening.?.cancel();
            try self.finishOpening();
        } else if (kind == .escape and self.rt.workspace.tab().pending != null) {
            self.rt.workspace.cancel();
            self.dirty = true;
        } else {
            if (input.terminal) |mapped| {
                try self.sendKey(mapped);
                self.suppress_text = mapped.key == .char;
            }
        }
    }
};
fn keyDirection(key: Key) ?model.Neighbor {
    return switch (key) {
        .h, .left => .left,
        .j, .down => .down,
        .k, .up => .up,
        .l, .right => .right,
        else => null,
    };
}

/// Pickers and Pane actions share navigation; editors retain ordinary j/k text.
fn menuKey(key: Key, editing: bool) ?picker_mod.Key {
    return switch (key) {
        .up => .up,
        .down => .down,
        .k => if (editing) null else .up,
        .j => if (editing) null else .down,
        .enter, .keypad_enter => .enter,
        .escape => .escape,
        .backspace => .backspace,
        else => null,
    };
}

test "copy shortcut owns its physical key until release" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.resize_mode = true;
    try ui.copyShortcut(99);
    try std.testing.expectEqual(@as(?u32, 99), ui.consumed_key);
    try std.testing.expect(ui.suppress_text and ui.resize_mode);
    try ui.keyDown(.{ .code = 99, .repeat = true });
    try std.testing.expect(ui.suppress_text and ui.resize_mode);
    ui.keyUp(99);
    try std.testing.expect(ui.consumed_key == null and !ui.suppress_text);
}

test "picker press is cancelled by geometry changes and focus loss" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    var next: u64 = 2;
    const opening = try std.testing.allocator.create(picker_mod.Picker);
    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, .{}) };
    try opening.show(.hosts);
    ui.opening = opening;
    const picker = ui.modalPicker().?;
    const row = picker.layout().rowRect(0);
    try ui.mouseDown(row.x, row.y, 0, 0, 0, .{});
    try std.testing.expect(picker.presentation.pressing());
    try ui.updateGeometry(799, 600, metrics);
    try ui.mouseUp(row.x, row.y, 0, .{});
    try std.testing.expect(ui.modalPicker().?.level == .hosts);
    const retry_row = ui.modalPicker().?.layout().rowRect(0);
    try ui.mouseDown(retry_row.x, retry_row.y, 0, 0, 0, .{});
    ui.focusLost();
    try ui.mouseUp(retry_row.x, retry_row.y, 0, .{});
    try std.testing.expect(ui.modalPicker().?.level == .hosts);
}

test "controller teardown clears a freed picker before capture is queried" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    try ui.open(.insert);
    try std.testing.expect(ui.modalPicker() != null);
    ui.deinit();
    try std.testing.expect(ui.opening == null and !ui.hasPointerCapture());
}

test "paste shortcut follows the active text destination" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    var next: u64 = 2;
    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, .{}) };
    defer picker.arena.deinit();
    defer picker.presentation.deinit();
    try picker.show(.session_name);
    ui.opening = &picker;
    defer ui.opening = null;
    try ui.pasteShortcut(86, "new-shell");
    try std.testing.expectEqualStrings("new-shell", picker.presentation.input.items);
    try std.testing.expectEqual(@as(?u32, 86), ui.consumed_key);
    ui.keyUp(86);
    ui.opening = null;
    ui.command_mode = true;
    try ui.pasteShortcut(86, "not terminal input");
    try std.testing.expect(ui.command_mode and ui.consumed_key == 86);
}

test "controller preserves exact held keys across modal dismissal and text edges" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.recovery = .{ .kind = .recovery, .key = .{ .pane = 1, .generation = 1 } };
    // Two platform keys share navigation meaning but have independent releases.
    try ui.keyDown(.{ .code = 101, .kind = .down });
    try ui.keyDown(.{ .code = 102, .kind = .down });
    try std.testing.expectEqual(@as(usize, 2), ui.recovery.?.selected);
    try ui.keyDown(.{ .code = 27, .kind = .escape });
    ui.keyUp(27);
    ui.keyUp(101);
    try std.testing.expect(ui.recovery == null and ui.modal_held.contains(102));
    try std.testing.expect(!ui.modal_held.contains(101));
    // Retained ownership is checked before prefix dispatch, even after dismissal.
    try ui.keyDown(.{ .code = 102, .prefix = true, .repeat = true });
    try std.testing.expect(ui.suppress_text and !ui.command_mode);
    try ui.textInput("ignored modal repeat");
    try std.testing.expect(!ui.suppress_text);
    ui.keyUp(102);
    try ui.keyDown(.{ .code = 102, .prefix = true });
    try std.testing.expect(ui.command_mode);
    ui.keyUp(102);
    // Unbound commands dismiss the hint but retain ownership until release.
    ui.dirty = false;
    try ui.keyDown(.{ .code = 115 });
    try std.testing.expect(!ui.command_mode and ui.dirty and ui.suppress_text);
    try std.testing.expectEqualStrings("", ui.notice);
    try std.testing.expectEqual(@as(?u32, 115), ui.consumed_key);
    ui.keyUp(115);
    try ui.keyDown(.{ .code = 102, .prefix = true });
    try ui.keyDown(.{ .code = 114, .kind = .r });
    try std.testing.expect(ui.resize_mode and !ui.command_mode);
    ui.focusLost();
    try std.testing.expect(!ui.resize_mode and !ui.command_mode and !ui.suppress_text);
    try std.testing.expect(ui.consumed_key == null and ui.modal_held.count() == 0);
    try ui.command(.f);
    try std.testing.expect(!rt.workspace.tab().fullscreen);
}

test "controller modal input takes precedence over the prefix" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.recovery = .{ .kind = .recovery, .key = .{ .pane = 1, .generation = 1 } };
    try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true });
    try std.testing.expect(ui.recovery != null and !ui.command_mode and ui.suppress_text);
    ui.recovery = null;
    ui.keyUp(92);
    ui.resize_mode = true;
    try ui.keyDown(.{ .code = 92, .prefix = true, .modified = true });
    try std.testing.expect(ui.resize_mode and !ui.command_mode and ui.suppress_text);
}

test "quick splits are nonmodal and cancellable; picker directions remain explicit" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    var ui: Controller = .{ .rt = &rt, .metrics = .{ .cell_w = 8, .cell_h = 16 }, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();

    // An empty workspace still needs an explicit target choice.
    try ui.command(.v);
    try std.testing.expect(ui.modalPicker() != null);
    try ui.keyDown(.{ .code = 27, .kind = .escape });
    ui.keyUp(27);

    const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, ui.metrics);
    try ui.command(.v);
    try std.testing.expect(ui.modalPicker() == null and ui.opening != null);
    try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
    try std.testing.expectEqual(id, ui.opening.?.origin.?.pane);
    try ui.keyDown(.{ .code = 65 });
    try std.testing.expect(!ui.suppress_text and ui.modal_held.count() == 0);
    ui.keyUp(65);
    const request = ui.opening.?.ticket;
    try ui.command(.b);
    try std.testing.expectEqual(request, ui.opening.?.ticket);
    try ui.keyDown(.{ .code = 27, .kind = .escape });
    ui.keyUp(27);
    try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null);

    try ui.command(.b);
    try std.testing.expectEqual(model.Direction.beside, ui.opening.?.pending.?.direction);
    try ui.command(.escape);

    try ui.command(.enter);
    try ui.keyDown(.{ .code = 118, .kind = .v });
    ui.keyUp(118);
    try std.testing.expectEqual(model.Direction.stacked, rt.workspace.tab().pending.?.direction);
    try std.testing.expectEqual(rt.workspace.tab().pending, ui.modalPicker().?.pending);
    try ui.keyDown(.{ .code = 27, .kind = .escape });
    ui.keyUp(27);
    try std.testing.expect(ui.modalPicker() == null and rt.workspace.tab().pending != null);
    try ui.keyDown(.{ .code = 27, .kind = .escape });
    ui.keyUp(27);
    try std.testing.expect(rt.workspace.tab().pending == null);

    try ui.updateGeometry(1, 1, ui.metrics);
    try ui.command(.b);
    try std.testing.expect(ui.opening == null and rt.workspace.tab().pending == null);
    try std.testing.expectEqualStrings("TooSmall", ui.notice);
}

test "beginEnd stays nonmodal and delayed refusal respects command mode" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    const id = try rt.add(.{ .via = "cat" }, "ending", 800, 600, metrics);
    var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    rt.get(id).?.pump.mu.lock();
    rt.get(id).?.pump.status.phase = .attached;
    rt.get(id).?.pump.mu.unlock();
    try ui.beginEnd(rt.get(id).?.key, false);
    try std.testing.expect(ui.recovery == null);
    try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request);
    ui.command_mode = true;
    rt.get(id).?.status.ending = .{ .request = ui.pending_end.?.request, .phase = .refused, .others = 1 };
    try ui.pollEnd();
    try std.testing.expect(ui.recovery == null);
    try std.testing.expect(ui.pending_end == null);
    try std.testing.expect(ui.command_mode);
}

test "wheel retains fractional deltas and emits whole notches" {
    var wheel: Wheel = .{};
    try std.testing.expectEqual(@as(i32, 0), wheel.notches(0.4, false));
    try std.testing.expectEqual(@as(i32, 1), wheel.notches(0.6, false));
    try std.testing.expectEqual(@as(i32, -1), wheel.notches(1, true));
}

test "selection hit keeps absolute history and pane-local pointer cells" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    const first = try rt.add(.{ .via = "cat" }, "history-a", 800, 600, metrics);
    const second = try rt.add(.{ .via = "cat" }, "history-b", 800, 600, metrics);
    var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.layout = rt.workspace.layout(800, 600, metrics);
    for (ui.layout.items()) |placement| {
        const live = rt.get(placement.id).?;
        try live.snapshot.resize(22, 9);
        live.view_origin = if (placement.id == first) 70_000 else 3;
    }
    const a = ui.layout.get(first).?;
    const b = ui.layout.get(second).?;
    const ha = ui.hit(a.content.x + 4, a.content.y + 16).?;
    const hb = ui.hit(b.content.x + 4, b.content.y + 16).?;
    try std.testing.expectEqual(@as(u32, 70_001), ha.row);
    try std.testing.expectEqual(@as(u32, 4), hb.row);
    try std.testing.expectEqual(@as(u16, 1), ui.cellFor(a.content.x + 4, a.content.y + 16).row);
    try std.testing.expectEqual(@as(u16, 1), ui.cellFor(b.content.x + 4, b.content.y + 16).row);
    try ui.pointerDown(a.content.x + 4, a.content.y + 16, 0, 0);
    try ui.pointerMove(b.content.x + 4, b.content.y + 32);
    try std.testing.expect(ui.selection_drag.buttonHeld());
    try std.testing.expectEqual(@as(u32, 70_001), ui.selection_drag.range().?.to.row);
    try ui.pointerMove(a.content.x + 12, a.content.y + 32);
    try std.testing.expectEqual(@as(u32, 70_002), ui.selection_drag.range().?.to.row);
    ui.focusLost();
    try std.testing.expect(!ui.selection_drag.buttonHeld());
    try std.testing.expect(ui.selection_drag.range() == null);
}

test "held selection copies restart the gesture and release starts another" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    const id = try rt.add(.{ .via = "cat" }, "copy-gesture", 800, 600, metrics);
    var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.layout = rt.workspace.layout(800, 600, metrics);
    const live = rt.get(id).?;
    live.pump.mu.lock();
    try live.snapshot.resize(22, 9);
    live.pump.status.phase = .attached;
    live.pump.admitted = true;
    live.pump.replica.state_since_attach = true;
    live.pump.follow_seq = live.pump.replica.last_seq;
    live.pump.follow_source = 1;
    const source_version = live.pump.selectionVersionLocked();
    live.pump.mu.unlock();
    const p = ui.layout.get(id).?.content;
    try ui.pointerDown(p.x + 4, p.y + 8, 0, 0);
    ui.selection_version = source_version;
    try ui.pointerMove(p.x + 20, p.y + 8);
    const first_range = ui.selection_drag.range().?;
    try std.testing.expectEqual(@as(u16, 2), first_range.to.col);
    try ui.copySelection();
    const first = ui.selection_gesture;
    try std.testing.expect(first != 0);
    try ui.pointerMove(p.x + 28, p.y + 8);
    const extended_range = ui.selection_drag.range().?;
    try std.testing.expectEqual(@as(u16, 3), extended_range.to.col);
    try std.testing.expect(extended_range.to.col != first_range.to.col);
    try ui.copySelection();
    try std.testing.expect(ui.selection_gesture != first);
    const second = ui.selection_gesture;
    try ui.pointerUp(p.x + 36, p.y + 8);
    try std.testing.expect(ui.selection_gesture != second);
    try std.testing.expectEqual(@as(u16, 4), ui.selection_drag.range().?.to.col);
}

test "wheel reuses an existing pane remainder before a vacant earlier slot" {
    var rt = runtime.Runtime.init(std.testing.allocator, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    const id = try rt.add(.{ .via = "cat" }, "fraction", 800, 600, metrics);
    var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 };
    defer ui.deinit();
    ui.layout = rt.workspace.layout(800, 600, metrics);
    const live = rt.get(id).?;
    ui.wheel_remainder[0] = .{ .key = .{ .pane = id + 1, .generation = 1 }, .wheel = .{ .fraction = 0.75 } };
    ui.wheel_remainder[1] = .{ .key = live.key, .wheel = .{ .fraction = 0.5 } };
    const rect = ui.layout.get(id).?.content;
    try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{});
    try std.testing.expectEqual(@as(f32, 0), ui.wheel_remainder[1].wheel.fraction);
    try std.testing.expectEqual(@as(f32, 0.75), ui.wheel_remainder[0].wheel.fraction);
    ui.wheel_remainder[1].key.?.generation +%= 1;
    try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{});
    try std.testing.expectEqual(@as(f32, 0.5), ui.wheel_remainder[1].wheel.fraction);
}