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);
}