a73x

src/gui/frame.zig

Ref:   Size: 83.0 KiB   History

//! SDL window, workspace input, and whole-frame painting of owned pane snapshots.
const std = @import("std");
const builtin = @import("builtin");
const client = @import("client");
const term = @import("term");
const input = @import("input");
const native_core = @import("native_core");
const model = native_core.workspace;
const runtime = native_core.runtime;
const picker_mod = native_core.picker;
const persistence = native_core.persistence;
const interaction = native_core.interaction;
const popover = native_core.popover;
const font = @import("font.zig");
const atlas = @import("atlas.zig");
const quads = @import("quads.zig");
const theme_mod = native_core.theme;
const gl = @import("gl.zig");
const bench = @import("bench.zig");

const c = @cImport({
    @cInclude("SDL3/SDL.h");
});

pub const Options = struct {
    target: ?client.Target = null,
    local_target: ?client.Target = null,
    state_path: ?[]const u8 = null,
    key_path: ?[]const u8 = null,
    session: []const u8 = "0",
    /// Face pixel size at 100% display scale.
    font_px: u16 = 16,
    font_families: []const [:0]const u8 = font.default_families,
    font_points: ?f64 = null,
    appearance: theme_mod.Theme = theme_mod.legacy,
    width: u32 = 960,
    height: u32 = 600,
    /// Optional integration FIFO; events use the ordinary window input paths.
    test_fifo: ?[]const u8 = null,
    forwards: []const client.forward.Rule = &.{},
};

/// SDL keycode + mods → the input event, or null for a key that types
/// (text input carries it) or means nothing to a session.
pub fn keyEvent(key: u32, mod: u16) ?input.Event {
    const ctrl = mod & c.SDL_KMOD_CTRL != 0;
    const alt = mod & c.SDL_KMOD_ALT != 0;
    const shift = mod & c.SDL_KMOD_SHIFT != 0;
    if (mod & c.SDL_KMOD_MODE != 0 or (ctrl and mod & c.SDL_KMOD_RALT != 0)) return null;
    const mods: input.Mods = .{ .ctrl = ctrl, .alt = alt, .shift = shift };
    const named: ?input.Key = switch (key) {
        c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter,
        c.SDLK_TAB => .tab,
        c.SDLK_BACKSPACE => .backspace,
        c.SDLK_ESCAPE => .escape,
        c.SDLK_UP => .up,
        c.SDLK_DOWN => .down,
        c.SDLK_LEFT => .left,
        c.SDLK_RIGHT => .right,
        c.SDLK_HOME => .home,
        c.SDLK_END => .end,
        c.SDLK_INSERT => .insert,
        c.SDLK_DELETE => .delete,
        c.SDLK_PAGEUP => .page_up,
        c.SDLK_PAGEDOWN => .page_down,
        c.SDLK_F1 => .f1,
        c.SDLK_F2 => .f2,
        c.SDLK_F3 => .f3,
        c.SDLK_F4 => .f4,
        c.SDLK_F5 => .f5,
        c.SDLK_F6 => .f6,
        c.SDLK_F7 => .f7,
        c.SDLK_F8 => .f8,
        c.SDLK_F9 => .f9,
        c.SDLK_F10 => .f10,
        c.SDLK_F11 => .f11,
        c.SDLK_F12 => .f12,
        else => null,
    };
    if (named) |k| return .{ .key = k, .mods = mods };
    // Modifier chords are encoded here; the event handler suppresses any
    // matching text event to avoid sending the character twice.
    if ((ctrl or alt) and key >= 0x20 and key < 0x7f) {
        return .{ .key = .char, .cp = @intCast(key), .mods = mods };
    }
    return null;
}

pub const Hook = union(enum) {
    text: []const u8,
    key: struct { code: u32, mods: u16 = 0 },
    click: struct { x: f32, y: f32 },
    pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32, button: u8 = 0, mods: ?u8 = null },
    wheel: struct { x: f32, y: f32, delta: f32, flipped: bool = false },
    state: []const u8,
    resize: struct { w: u32, h: u32 },
    capture: []const u8,
    capture_last: []const u8,
    clipboard_set: []const u8,
    clipboard: []const u8,
    primary: []const u8,
    quit,
};

pub fn parseHook(line: []const u8) ?Hook {
    if (std.mem.eql(u8, line, "quit")) return .quit;
    if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] };
    if (std.mem.startsWith(u8, line, "mouse:")) {
        var it = std.mem.splitScalar(u8, line[6..], ',');
        const name = it.next() orelse return null;
        const kind: @FieldType(@FieldType(Hook, "pointer"), "kind") = if (std.mem.eql(u8, name, "down")) .down else if (std.mem.eql(u8, name, "move")) .motion else if (std.mem.eql(u8, name, "up")) .up else return null;
        const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null;
        const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null;
        const button = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null;
        const mods = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null;
        if (button > 2 or mods > 7 or it.next() != null) return null;
        return .{ .pointer = .{ .kind = kind, .x = x, .y = y, .button = button, .mods = mods } };
    }
    inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| {
        if (std.mem.startsWith(u8, line, entry[0])) {
            const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null;
            return .{ .pointer = .{ .kind = entry[1], .x = std.fmt.parseFloat(f32, line[entry[0].len..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } };
        }
    }
    if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] };
    if (std.mem.startsWith(u8, line, "clipboard-set:")) return .{ .clipboard_set = line[14..] };
    if (std.mem.startsWith(u8, line, "primary:")) return .{ .primary = line[8..] };
    if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] };
    if (std.mem.startsWith(u8, line, "wheel:")) {
        var it = std.mem.splitScalar(u8, line[6..], ',');
        const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null;
        const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null;
        const delta = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null;
        const flipped = if (it.next()) |v| std.mem.eql(u8, v, "flipped") else false;
        return .{ .wheel = .{ .x = x, .y = y, .delta = delta, .flipped = flipped } };
    }
    if (std.mem.startsWith(u8, line, "click:")) {
        const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null;
        return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } };
    }
    if (std.mem.startsWith(u8, line, "capture:")) return .{ .capture = line[8..] };
    if (std.mem.startsWith(u8, line, "text:")) return .{ .text = line["text:".len..] };
    if (std.mem.startsWith(u8, line, "key:")) {
        const name = line["key:".len..];
        if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } };
        if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
        if (std.mem.eql(u8, name, "paste")) return .{ .key = .{ .code = c.SDLK_V, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } };
        if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } };
        if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbfrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } };
        inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
            if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } };
        }
        return null;
    }
    if (std.mem.startsWith(u8, line, "resize:")) {
        const rest = line["resize:".len..];
        const x = std.mem.indexOfScalar(u8, rest, 'x') orelse return null;
        const w = std.fmt.parseInt(u32, rest[0..x], 10) catch return null;
        const h = std.fmt.parseInt(u32, rest[x + 1 ..], 10) catch return null;
        if (w == 0 or h == 0 or w > 16384 or h > 16384) return null;
        return .{ .resize = .{ .w = w, .h = h } };
    }
    return null;
}

var usr1_seen = std.atomic.Value(bool).init(false);

fn onUsr1(_: c_int) callconv(.c) void {
    usr1_seen.store(true, .release);
}

const Wake = struct {
    event_type: u32,
    fn ring(ctx: ?*anyopaque, key: model.Attachment) void {
        const self: *Wake = @ptrCast(@alignCast(ctx.?));
        var ev = std.mem.zeroes(c.SDL_Event);
        ev.type = self.event_type;
        ev.user.data1 = @ptrFromInt(key.pane);
        ev.user.data2 = @ptrFromInt(key.generation);
        // Runtime also polls atomic pending flags, so a full SDL queue cannot
        // strand an attachment. The queue holds IDs, never freed Live pointers.
        _ = c.SDL_PushEvent(&ev);
    }
    fn discovery(ctx: ?*anyopaque, ticket: client.discovery.Ticket) void {
        const self: *Wake = @ptrCast(@alignCast(ctx.?));
        var ev = std.mem.zeroes(c.SDL_Event);
        ev.type = self.event_type;
        ev.user.code = 1;
        ev.user.data1 = @ptrFromInt(ticket.generation);
        ev.user.data2 = @ptrFromInt(ticket.owner);
        _ = c.SDL_PushEvent(&ev);
    }
};

/// The optional test FIFO is read without blocking on the window thread.
/// It injects ordinary input events and calls the actual window resize API.
/// No detached worker can outlive the window or retain text-event pointers.
const HookReader = struct {
    alloc: std.mem.Allocator,
    fd: std.posix.fd_t,
    bytes: [8192]u8 = undefined,
    used: usize = 0,
    text: std.ArrayListUnmanaged([:0]u8) = .empty,
    capture: ?[]u8 = null,
    state: ?[]u8 = null,
    capture_last: ?[]u8 = null,
    retain_frame: bool = false,
    pixels: []u8 = &.{},
    pixel_w: u32 = 0,
    pixel_h: u32 = 0,

    fn init(alloc: std.mem.Allocator, path: []const u8) !HookReader {
        return .{ .alloc = alloc, .fd = try std.posix.open(path, .{ .ACCMODE = .RDONLY, .NONBLOCK = true, .CLOEXEC = true }, 0), .retain_frame = if (std.posix.getenv("MUXG_TEST_RETAIN_FRAME")) |v| std.mem.eql(u8, v, "1") else false };
    }

    fn deinit(self: *HookReader) void {
        std.posix.close(self.fd);
        for (self.text.items) |t| self.alloc.free(t);
        self.text.deinit(self.alloc);
        if (self.capture) |p| self.alloc.free(p);
        if (self.state) |p| self.alloc.free(p);
        if (self.capture_last) |p| self.alloc.free(p);
        self.alloc.free(self.pixels);
    }

    fn releaseText(self: *HookReader, p: [*c]const u8) void {
        for (self.text.items, 0..) |t, i| {
            if (t.ptr == p) {
                self.alloc.free(self.text.swapRemove(i));
                return;
            }
        }
    }

    fn read(self: *HookReader, win: *c.SDL_Window) !void {
        // Bound hook traffic just like the normal event queue.
        const n = std.posix.read(self.fd, self.bytes[self.used..]) catch |err| switch (err) {
            error.WouldBlock => return,
            else => return err,
        };
        self.used += n;
        var start: usize = 0;
        while (std.mem.indexOfScalarPos(u8, self.bytes[0..self.used], start, '\n')) |end| {
            if (parseHook(self.bytes[start..end])) |hook| try self.inject(win, hook);
            start = end + 1;
        }
        std.mem.copyForwards(u8, &self.bytes, self.bytes[start..self.used]);
        self.used -= start;
        if (self.used == self.bytes.len) return error.TestHookLineTooLong;
    }

    fn inject(self: *HookReader, win: *c.SDL_Window, hook: Hook) !void {
        var ev: c.SDL_Event = std.mem.zeroes(c.SDL_Event);
        switch (hook) {
            .text => |t| {
                const z = try self.alloc.dupeZ(u8, t);
                errdefer self.alloc.free(z);
                try self.text.append(self.alloc, z);
                ev.text.type = c.SDL_EVENT_TEXT_INPUT;
                ev.text.windowID = c.SDL_GetWindowID(win);
                ev.text.text = z.ptr;
            },
            .key => |k| {
                ev.key.type = c.SDL_EVENT_KEY_DOWN;
                ev.key.windowID = c.SDL_GetWindowID(win);
                ev.key.key = k.code;
                ev.key.mod = k.mods;
                if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed;
                if (k.code >= 0x20 and k.code < 0x7f) {
                    const letter = [_]u8{@intCast(k.code)};
                    try self.inject(win, .{ .text = &letter });
                }
                ev.key.type = c.SDL_EVENT_KEY_UP;
            },
            .click => |at| {
                ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_DOWN;
                ev.button.button = c.SDL_BUTTON_LEFT;
                ev.button.x = at.x;
                ev.button.y = at.y;
                if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed;
                ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP;
            },
            .pointer => |at| {
                if (at.mods) |mods| {
                    // Queue modifier state with the ordinary SDL event, so
                    // multiple FIFO gestures retain their own event ordering.
                    ev.user.type = c.SDL_EVENT_USER;
                    ev.user.code = 0x4d4f4453;
                    ev.user.data1 = @ptrFromInt(@as(usize, mods));
                    if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed;
                    ev = std.mem.zeroes(c.SDL_Event);
                }
                if (at.kind == .motion) {
                    ev.motion.type = c.SDL_EVENT_MOUSE_MOTION;
                    ev.motion.state = c.SDL_BUTTON_LMASK;
                    ev.motion.x = at.x;
                    ev.motion.y = at.y;
                } else {
                    ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP;
                    ev.button.button = switch (at.button) {
                        1 => c.SDL_BUTTON_MIDDLE,
                        2 => c.SDL_BUTTON_RIGHT,
                        else => c.SDL_BUTTON_LEFT,
                    };
                    ev.button.x = at.x;
                    ev.button.y = at.y;
                }
            },
            .wheel => |at| {
                ev.wheel.type = c.SDL_EVENT_MOUSE_WHEEL;
                ev.wheel.mouse_x = at.x;
                ev.wheel.mouse_y = at.y;
                ev.wheel.x = 0;
                ev.wheel.y = at.delta;
                ev.wheel.integer_x = 0;
                ev.wheel.integer_y = 0;
                ev.wheel.direction = if (at.flipped) c.SDL_MOUSEWHEEL_FLIPPED else c.SDL_MOUSEWHEEL_NORMAL;
            },
            .state => |path| {
                const copy = try self.alloc.dupe(u8, path);
                if (self.state) |old| self.alloc.free(old);
                self.state = copy;
                return;
            },
            .resize => |r| {
                if (!c.SDL_SetWindowSize(win, @intCast(r.w), @intCast(r.h))) return error.WindowResizeFailed;
                return;
            },
            .capture => |p| {
                const copy = try self.alloc.dupe(u8, p);
                if (self.capture) |old| self.alloc.free(old);
                self.capture = copy;
                return;
            },
            .capture_last => |path| {
                if (!self.retain_frame) return error.FrameRetentionDisabled;
                const copy = try self.alloc.dupe(u8, path);
                if (self.capture_last) |old| self.alloc.free(old);
                self.capture_last = copy;
                return;
            },
            .clipboard_set => |path| {
                const text = try std.fs.cwd().readFileAlloc(self.alloc, path, term.protocol.max_payload);
                defer self.alloc.free(text);
                if (!client.core.validClipboardText(text)) return error.InvalidClipboardText;
                const z = try self.alloc.dupeZ(u8, text);
                defer self.alloc.free(z);
                if (!c.SDL_SetClipboardText(z.ptr)) return error.ClipboardWriteFailed;
                return;
            },
            .clipboard, .primary => |path| {
                const text = (if (hook == .primary) c.SDL_GetPrimarySelectionText() else c.SDL_GetClipboardText()) orelse return error.ClipboardReadFailed;
                defer c.SDL_free(@ptrCast(text));
                const file = try std.fs.cwd().createFile(path, .{ .truncate = true });
                defer file.close();
                try file.writeAll(std.mem.span(text));
                return;
            },
            .quit => ev.type = c.SDL_EVENT_QUIT,
        }
        if (!c.SDL_PushEvent(&ev)) {
            if (hook == .text) self.releaseText(ev.text.text);
            return error.EventInjectionFailed;
        }
    }
};

fn sdlFail(op: []const u8) u8 {
    std.debug.print("muxg: {s}: {s}\n", .{ op, std.mem.span(c.SDL_GetError()) });
    return 2;
}

/// This state belongs to the window thread. Geometry and input select stable
/// pane IDs; transport ownership lives in Runtime and rendering uses snapshots.
const Recovery = interaction.Recovery;
const Events = struct {
    ui: interaction.Controller,
    win: *c.SDL_Window,
    wake: *Wake,
    hook: ?*HookReader,
    cache: *font.GlyphCache,
    base_font_px: u16,
    base_font_points: ?f64 = null,
    geometry_dirty: bool = false,
    logical_w: c_int = 0,
    logical_h: c_int = 0,
    captured: bool = false,

    fn deinit(self: *Events) void {
        self.ui.deinit();
        self.syncCapture();
    }
    fn syncCapture(self: *Events) void {
        const capturing = self.ui.hasPointerCapture();
        if (self.captured != capturing) {
            _ = c.SDL_CaptureMouse(capturing);
            self.captured = capturing;
        }
    }
    /// Commit events sample the actual drawable even if its resize notice
    /// is still behind this event in SDL's bounded queue.
    fn dispatch(self: *Events, ev: c.SDL_Event) !bool {
        if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.command_mode or self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) {
            self.geometry_dirty = true;
            try self.refreshGeometry();
        }
        return self.handle(ev);
    }
    fn handle(self: *Events, ev: c.SDL_Event) !bool {
        defer self.syncCapture();
        std.debug.assert(self.ui.modalPicker() == null or self.ui.recovery == null);
        switch (ev.type) {
            c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
            c.SDL_EVENT_TEXT_INPUT => {
                defer if (self.hook) |h| h.releaseText(ev.text.text);
                try self.ui.textInput(std.mem.span(ev.text.text));
            },
            c.SDL_EVENT_KEY_DOWN => if (nativeShortcut(ev.key, builtin.os.tag == .macos)) |shortcut| switch (shortcut) {
                .copy => try self.ui.copyShortcut(ev.key.key),
                .paste => {
                    const text = c.SDL_GetClipboardText() orelse {
                        self.ui.consumeShortcut(ev.key.key);
                        self.ui.setNotice("Clipboard read failed");
                        return true;
                    };
                    defer c.SDL_free(@ptrCast(text));
                    try self.ui.pasteShortcut(ev.key.key, std.mem.span(text));
                },
            } else try self.ui.keyDown(interactionKey(ev.key)),
            c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key),
            c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(),
            c.SDL_EVENT_USER => if (ev.user.code == 0x4d4f4453) {
                const mods = @intFromPtr(ev.user.data1);
                c.SDL_SetModState(@as(c.SDL_Keymod, if (mods & 1 != 0) c.SDL_KMOD_SHIFT else 0) | @as(c.SDL_Keymod, if (mods & 2 != 0) c.SDL_KMOD_ALT else 0) | @as(c.SDL_Keymod, if (mods & 4 != 0) c.SDL_KMOD_CTRL else 0));
            },
            c.SDL_EVENT_MOUSE_BUTTON_DOWN => {
                const button = mouseButton(ev.button.button) orelse return true;
                var w: c_int = 0;
                var h: c_int = 0;
                if (c.SDL_GetWindowSize(self.win, &w, &h)) {
                    const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h);
                    try self.ui.mouseDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h), button, mouseMods());
                }
            },
            c.SDL_EVENT_MOUSE_WHEEL => {
                var w: c_int = 0;
                var h: c_int = 0;
                if (c.SDL_GetWindowSize(self.win, &w, &h)) {
                    const at = physicalPoint(ev.wheel.mouse_x, ev.wheel.mouse_y, w, h, self.ui.fb_w, self.ui.fb_h);
                    try self.ui.wheel(at.x, at.y, ev.wheel.y, ev.wheel.direction == c.SDL_MOUSEWHEEL_FLIPPED, mouseMods());
                }
            },
            c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => {
                const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION;
                const button = if (motion) 0 else mouseButton(ev.button.button) orelse return true;
                var w: c_int = 0;
                var h: c_int = 0;
                if (c.SDL_GetWindowSize(self.win, &w, &h)) {
                    const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w);
                    const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h);
                    if (x != null and y != null) {
                        if (motion) try self.ui.mouseMove(x.?, y.?, mouseMods()) else {
                            if (button == 0 and self.ui.drag != null) try self.ui.pointerMove(x.?, y.?);
                            try self.ui.mouseUp(@intCast(@max(x.?, 0)), @intCast(@max(y.?, 0)), button, mouseMods());
                        }
                    } else if (self.ui.app_drag != null) {
                        self.ui.cancelMouse();
                    } else if (motion) {
                        try self.ui.pointerMove(-1, -1);
                    } else if (button == 0) {
                        try self.ui.mouseUp(0, 0, button, mouseMods());
                    }
                }
            },
            c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true,
            c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true,
            else => if (ev.type == self.wake.event_type and (ev.user.code == 1 or self.ui.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) }))) {
                self.ui.dirty = true;
            },
        }
        return true;
    }
    fn refreshGeometry(self: *Events) !void {
        if (!self.geometry_dirty) return;
        var w: c_int = 0;
        var h: c_int = 0;
        if (!c.SDL_GetWindowSizeInPixels(self.win, &w, &h)) return error.WindowSizeFailed;
        var logical_w: c_int = 0;
        var logical_h: c_int = 0;
        if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed;
        if (logical_w != self.logical_w or logical_h != self.logical_h) {
            self.ui.cancelDrag();
            self.ui.clearSelection();
        }
        self.logical_w = logical_w;
        self.logical_h = logical_h;
        try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win));
    }
    fn updateGeometry(self: *Events, w: c_int, h: c_int, scale: f32) !void {
        defer self.syncCapture();
        _ = try self.cache.setPixelSize(if (self.base_font_points) |points| font.scaledPoints(points, scale) else font.scaledPixels(self.base_font_px, scale));
        const metrics = measuredMetrics(self.cache.fonts.primary(), scale);
        try self.ui.updateGeometry(w, h, metrics);
        self.geometry_dirty = false;
    }
};
fn measuredMetrics(face: *const font.Face, scale: f32) model.Metrics {
    return .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .divider = @intFromFloat(@round(if (std.math.isFinite(scale)) std.math.clamp(scale, 1, 32) else 1)) };
}
fn physicalPoint(x: f32, y: f32, logical_w: c_int, logical_h: c_int, fb_w: c_int, fb_h: c_int) struct { x: u32, y: u32 } {
    return .{ .x = physicalAxis(x, logical_w, fb_w), .y = physicalAxis(y, logical_h, fb_h) };
}
fn physicalAxis(v: f32, logical: c_int, pixels: c_int) u32 {
    const value = physicalSignedAxis(v, logical, pixels) orelse return std.math.maxInt(u32);
    return if (value < 0) std.math.maxInt(u32) else @intCast(value);
}
fn physicalSignedAxis(v: f32, logical: c_int, pixels: c_int) ?i64 {
    if (!std.math.isFinite(v) or logical <= 0 or pixels <= 0) return null;
    return @intFromFloat(std.math.clamp(@floor(@as(f64, v) * @as(f64, @floatFromInt(pixels)) / @as(f64, @floatFromInt(logical))), -2147483648, 2147483648));
}
fn grabPixels(logical: c_int, pixels: c_int) u32 {
    if (logical <= 0 or pixels <= 0) return 0;
    return @intCast((@as(u64, @intCast(pixels)) * 6 + @as(u32, @intCast(logical)) - 1) / @as(u32, @intCast(logical)));
}
const NativeShortcut = enum { copy, paste };
fn nativeShortcut(ev: c.SDL_KeyboardEvent, macos: bool) ?NativeShortcut {
    if (ev.repeat) return null;
    if (ev.key == c.SDLK_C and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0) return .copy;
    if (ev.key == c.SDLK_V and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0) return .paste;
    if (macos and ev.key == c.SDLK_V and ev.mod & c.SDL_KMOD_GUI != 0 and ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_SHIFT | c.SDL_KMOD_MODE) == 0) return .paste;
    return null;
}
fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown {
    const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key;
    const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
    return .{
        .code = ev.key,
        .kind = switch (ev.key) {
            c.SDLK_V => .v,
            c.SDLK_B => .b,
            c.SDLK_F => .f,
            c.SDLK_R => .r,
            c.SDLK_D => .d,
            c.SDLK_X => .x,
            c.SDLK_P => .p,
            c.SDLK_ESCAPE => .escape,
            c.SDLK_RETURN => .enter,
            c.SDLK_KP_ENTER => .keypad_enter,
            c.SDLK_UP => .up,
            c.SDLK_K => .k,
            c.SDLK_DOWN => .down,
            c.SDLK_J => .j,
            c.SDLK_LEFT => .left,
            c.SDLK_H => .h,
            c.SDLK_RIGHT => .right,
            c.SDLK_L => .l,
            c.SDLK_BACKSPACE => .backspace,
            else => .other,
        },
        .prefix = prefix,
        .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0,
        .repeat = ev.repeat,
        .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod),
    };
}

pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
    var forward_owner: ?*client.forward.Manager = if (opts.forwards.len != 0)
        try client.forward.Manager.init(alloc, opts.target orelse return error.ForwardRequiresTarget, opts.forwards)
    else
        null;
    errdefer if (forward_owner) |manager| manager.stop();
    const trial = opts.test_fifo != null and std.mem.eql(u8, std.posix.getenv("MUXG_TEST_THEME") orelse "", "trial");
    const configured_appearance = opts.appearance;
    const appearance = if (trial) &theme_mod.trial else &configured_appearance;
    var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null;
    defer if (store) |*s| s.deinit();
    var load_notice: [256]u8 = @splat(0);
    var load_notice_len: usize = 0;
    var saved: ?model.Workspace = if (store) |*s| s.load() catch |err| blk: {
        const text = try std.fmt.bufPrint(&load_notice, "Workspace preserved; saving disabled: {s}", .{@errorName(err)});
        load_notice_len = text.len;
        break :blk null;
    } else null;
    defer if (saved) |*workspace| workspace.deinit();
    var ring: bench.Ring = .{};
    defer report(&ring);
    usr1_seen.store(false, .release);
    const sa: std.posix.Sigaction = .{ .handler = .{ .handler = onUsr1 }, .mask = std.posix.sigemptyset(), .flags = 0 };
    var old_sa: std.posix.Sigaction = undefined;
    std.posix.sigaction(std.posix.SIG.USR1, &sa, &old_sa);
    defer std.posix.sigaction(std.posix.SIG.USR1, &old_sa, null);

    // A low-resolution X11 window may be scaled by the compositor. Prefer
    // native Wayland, while leaving both explicit driver variables in control.
    if (@import("builtin").os.tag == .linux and std.posix.getenv("SDL_VIDEO_DRIVER") == null and std.posix.getenv("SDL_VIDEODRIVER") == null) {
        _ = c.SDL_SetHintWithPriority(c.SDL_HINT_VIDEO_DRIVER, "wayland,x11", c.SDL_HINT_DEFAULT);
    }
    if (!c.SDL_Init(c.SDL_INIT_VIDEO)) return sdlFail("SDL_Init");
    defer c.SDL_Quit();
    _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
    _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 3);
    _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_CORE);
    const win = c.SDL_CreateWindow("muxg", @intCast(opts.width), @intCast(opts.height), c.SDL_WINDOW_OPENGL | c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_HIGH_PIXEL_DENSITY) orelse return sdlFail("SDL_CreateWindow");
    defer c.SDL_DestroyWindow(win);
    const raster_px = if (opts.font_points) |points| font.scaledPoints(points, c.SDL_GetWindowDisplayScale(win)) else font.scaledPixels(opts.font_px, c.SDL_GetWindowDisplayScale(win));
    var failed_family: usize = 0;
    var fonts = font.FontSet.open(alloc, raster_px, opts.font_families, &failed_family) catch |err| {
        const family = if (failed_family < opts.font_families.len) opts.font_families[failed_family] else "<none>";
        std.debug.print("muxg: font family '{s}': {s}\n", .{ family, @errorName(err) });
        return 2;
    };
    defer fonts.deinit(alloc);
    const context = c.SDL_GL_CreateContext(win) orelse return sdlFail("SDL_GL_CreateContext");
    defer _ = c.SDL_GL_DestroyContext(context);
    _ = c.SDL_GL_SetSwapInterval(1);
    var renderer = gl.Renderer.init(@ptrCast(&c.SDL_GL_GetProcAddress)) catch |err| {
        std.debug.print("muxg: OpenGL: {s}\n", .{@errorName(err)});
        return 2;
    };
    defer renderer.deinit();
    if (!c.SDL_StartTextInput(win)) return sdlFail("SDL_StartTextInput");
    var glyph_atlas = try atlas.Atlas.init(alloc, font.atlasWidth(raster_px), 256);
    defer glyph_atlas.deinit(alloc);
    var cache: font.GlyphCache = .{ .alloc = alloc, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
    defer cache.deinit();
    var lists: quads.Lists = .{};
    defer lists.deinit(alloc);
    var instances: std.ArrayListUnmanaged(quads.Instance) = .empty;
    defer instances.deinit(alloc);

    var fb_w: c_int = 0;
    var fb_h: c_int = 0;
    if (!c.SDL_GetWindowSizeInPixels(win, &fb_w, &fb_h)) return sdlFail("SDL_GetWindowSizeInPixels");
    const metrics = measuredMetrics(fonts.primary(), c.SDL_GetWindowDisplayScale(win));
    var wake: Wake = .{ .event_type = c.SDL_RegisterEvents(1) };
    if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents");
    var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring });
    defer rt.deinit();
    if (saved) |workspace| {
        rt.workspace.deinit();
        rt.workspace = workspace;
        saved = null;
        const layout = rt.workspace.layout(@intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
        try rt.restore(&layout);
    } else if (opts.target) |target| _ = try rt.add(target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
    if (forward_owner) |manager| {
        try manager.start();
        rt.setForwarding(manager);
        forward_owner = null;
    }
    defer if (store) |*s| s.save(&rt.workspace) catch |err| {
        std.debug.print("muxg: workspace not saved: {s}\n", .{@errorName(err)});
    };
    var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
    defer if (hook) |*h| h.deinit();
    var events: Events = .{ .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .base_font_points = opts.font_points, .ui = .{ .rt = &rt, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len, .wake_ctx = &wake, .wake = Wake.discovery } };
    defer events.deinit();
    try events.ui.relayout();
    if (events.ui.layout.len == 0) try events.ui.open(.insert);
    var headers: [model.max_panes]Header = @splat(.{});
    var popup: PopupFrame = .{};
    var popup_lists: quads.Lists = .{};
    defer popup_lists.deinit(alloc);
    var visible_blink = false;
    var blink_phase = true;
    var blink_until: i64 = 0;

    while (true) {
        if (hook) |*h| {
            try h.read(win);
            if (h.capture != null) events.ui.dirty = true;
        }
        var ev: c.SDL_Event = undefined;
        const wait_ms: c_int = if (events.ui.dirty) 0 else if (hook != null) 16 else 100;
        if (c.SDL_WaitEventTimeout(&ev, wait_ms)) {
            if (!try events.dispatch(ev)) return 0;
            var consumed: usize = 1;
            while (consumed < 128 and c.SDL_PollEvent(&ev)) : (consumed += 1) {
                if (!try events.dispatch(ev)) return 0;
            }
        }
        try events.refreshGeometry();
        if (usr1_seen.swap(false, .acq_rel)) report(&ring);
        const now = std.time.milliTimestamp();
        events.ui.dirty = events.ui.poll(now) or events.ui.dirty;
        // Every effect is drained from its source attachment; focus does not
        // redirect another pane's write into a selection request.
        for (events.ui.rt.lives) |slot| if (slot) |live| {
            inline for (.{ false, true }) |primary| if (live.pump.takeClipboard(primary)) |text| {
                defer alloc.free(text);
                try setClipboard(&events.ui, text, primary);
            };
        };
        if (events.ui.takeSelectionText()) |text| {
            defer alloc.free(text);
            try setClipboard(&events.ui, text, false);
        }
        try events.ui.pollEnd();
        events.syncCapture();
        if (events.ui.requestReady()) {
            events.geometry_dirty = true;
            try events.refreshGeometry();
            try events.ui.pollOpening();
        }
        events.ui.saveIntent();
        if (hook) |*h| if (h.state) |path| {
            defer alloc.free(path);
            h.state = null;
            try writeState(alloc, path, &events);
        };
        if (hook) |*h| if (h.capture_last) |path| if (h.pixels.len != 0) {
            defer alloc.free(path);
            h.capture_last = null;
            try writePixels(alloc, path, h.pixel_w, h.pixel_h, h.pixels);
        };
        if (visible_blink and now >= blink_until) {
            blink_phase = !blink_phase;
            blink_until = now + 500;
            events.ui.dirty = true;
        }
        if (!events.ui.dirty or events.ui.fb_w <= 0 or events.ui.fb_h <= 0) continue;
        events.ui.dirty = false;
        var timer = try std.time.Timer.start();
        var timing: bench.Frame = .{};
        instances.clearRetainingCapacity();
        lists.backgrounds.clearRetainingCapacity();
        lists.foregrounds.clearRetainingCapacity();
        // Freeze each pane under its own mutex. No transport locks are held
        // during shaping, atlas growth, instance generation, or GL calls.
        for (events.ui.layout.items(), 0..) |p, i| {
            const live = rt.get(p.id).?;
            if (p.visible.w == 0 or p.visible.h == 0) continue;
            timing.apply_us = @max(timing.apply_us, try live.capture(p.cols, p.rows));
            headers[i].set(&events, p, live);
        }
        // Prepare ALL pane and header glyphs before normalizing ANY UV. A
        // later pane may grow the shared atlas and change every earlier UV.
        for (events.ui.layout.items(), 0..) |p, i| {
            if (p.visible.w == 0 or p.visible.h == 0) continue;
            try prepareGrid(&cache, rt.get(p.id).?.snapshot);
            var header_row = headers[i].row();
            try prepareRow(&cache, &header_row);
        }
        popup.len = 0;
        if (events.ui.modalPicker()) |picker| {
            popup.setPopover(picker.layout(), picker.presentation.view());
        } else if (events.ui.recovery) |menu| popup.setRecovery(menu, &events) else if (events.ui.layout.len == 0) popup.setEmpty(&events);
        if (events.ui.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.save_notice[0..events.ui.save_notice_len], popup.rect.w / events.ui.metrics.cell_w);
        if (events.ui.modalPicker() != null and events.ui.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.ui.notice, popup.rect.w / events.ui.metrics.cell_w);
        for (popup.lines[0..popup.len]) |*line| {
            var row = line.row();
            try prepareRow(&cache, &row);
        }
        const had_blink = visible_blink;
        visible_blink = false;
        const primary = fonts.primary();
        const base_ctx: quads.Ctx = .{ .cell_w = primary.cell_w, .cell_h = primary.cell_h, .ascent = primary.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase, .theme = appearance };
        for (events.ui.layout.items(), 0..) |p, i| {
            if (p.visible.w == 0 or p.visible.h == 0) continue;
            const live = rt.get(p.id).?;
            const grid = live.snapshot;
            // Opaque content rectangles leave the clear color in dividers.
            try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(p.content.x), @floatFromInt(p.content.y), @floatFromInt(p.content.w), @floatFromInt(p.content.h), appearance.terminal_bg));
            var ctx = base_ctx;
            ctx.x0 = @floatFromInt(p.content.x);
            ctx.y0 = @floatFromInt(p.content.y);
            const bg_start = lists.backgrounds.items.len;
            const fg_start = lists.foregrounds.items.len;
            for (0..grid.rows) |y| {
                ctx.selection = if (events.ui.selectedSpan(p.id, live.view_origin + @as(u32, @intCast(y)), grid.cols)) |s| .{ .from = s.from, .to = s.to } else null;
                visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink;
            }
            if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx));
            clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible));
            ctx.x0 = @floatFromInt(p.header.x);
            ctx.y0 = @floatFromInt(p.header.y);
            const focused = rt.workspace.tab().focus == p.id;
            const bell = live.bell_until != 0;
            ctx.fg = if (bell) appearance.bell_header_fg else if (focused) appearance.chrome_focus_fg else appearance.chrome_unfocused_fg;
            ctx.bg = if (bell) appearance.bell_header_bg else if (focused) appearance.chrome_focus_bg else appearance.chrome_unfocused_bg;
            try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(p.header.w), @floatFromInt(p.header.h), ctx.bg.?));
            const header_start = lists.foregrounds.items.len;
            var header_row = headers[i].row();
            _ = try quads.rowInstances(&lists, alloc, &header_row, @intCast(header_row.cells.len), 0, 0, ctx);
            clipPane(&lists, lists.backgrounds.items.len, header_start, model.Rect.intersect(p.header, p.visible));
        }
        if (visible_blink and !had_blink) blink_until = now + 500;
        if (!visible_blink) blink_phase = true;
        try lists.flatten(&instances, alloc);
        // Modal background and glyphs are one final layer above all pane
        // foregrounds. Its glyphs were prepared before the shared UV pass.
        popup_lists.backgrounds.clearRetainingCapacity();
        popup_lists.foregrounds.clearRetainingCapacity();
        try popup.emit(&popup_lists, alloc, base_ctx);
        try popup_lists.flatten(&instances, alloc);
        timing.rebuild_us = bench.usSince(&timer);
        if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas);
        timing.atlas_us = bench.usSince(&timer);
        renderer.uploadInstances(instances.items);
        timing.upload_us = bench.usSince(&timer);
        renderer.draw(instances.items.len, events.ui.fb_w, events.ui.fb_h, appearance.divider);
        if (hook) |*h| if (h.capture != null or h.retain_frame) {
            const pixels = try renderer.readPixels(alloc, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h));
            var owned = true;
            defer if (owned) alloc.free(pixels);
            if (h.capture) |path| {
                defer alloc.free(path);
                h.capture = null;
                try writePixels(alloc, path, @intCast(events.ui.fb_w), @intCast(events.ui.fb_h), pixels);
            }
            if (h.retain_frame) {
                alloc.free(h.pixels);
                h.pixels = pixels;
                h.pixel_w = @intCast(events.ui.fb_w);
                h.pixel_h = @intCast(events.ui.fb_h);
                owned = false;
            }
        };
        if (!c.SDL_GL_SwapWindow(win)) return sdlFail("SDL_GL_SwapWindow");
        for (events.ui.layout.items()) |p| if (p.visible.w != 0 and p.visible.h != 0) {
            const live = rt.get(p.id).?;
            live.painted_seq = live.snapshot_seq;
        };
        timing.draw_us = bench.usSince(&timer);
        ring.record(timing);
    }
}

fn report(ring: *const bench.Ring) void {
    var buf: [2048]u8 = undefined;
    std.debug.print("{s}", .{ring.report(&buf)});
}

test "key mapping sends modifier punctuation and space through the shared encoder" {
    try std.testing.expect(keyEvent(c.SDLK_A, 0) == null);
    try std.testing.expectEqual(input.Key.up, keyEvent(c.SDLK_UP, 0).?.key);
    var buf: [input.max_seq_len]u8 = undefined;
    const cases = .{ .{ c.SDLK_BACKSLASH, @as(u8, 28) }, .{ c.SDLK_LEFTBRACKET, @as(u8, 27) }, .{ c.SDLK_RIGHTBRACKET, @as(u8, 29) }, .{ c.SDLK_SPACE, @as(u8, 0) } };
    inline for (cases) |pair| {
        try std.testing.expectEqualSlices(u8, &.{pair[1]}, input.encode(keyEvent(pair[0], c.SDL_KMOD_CTRL).?, &buf));
    }
    try std.testing.expectEqualSlices(u8, &.{ 27, 'X' }, input.encode(keyEvent('X', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf));
    try std.testing.expectEqualSlices(u8, &.{ 27, '!' }, input.encode(keyEvent('!', c.SDL_KMOD_LALT | c.SDL_KMOD_LSHIFT).?, &buf));
    try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_MODE | c.SDL_KMOD_RALT) == null);
    try std.testing.expect(keyEvent(c.SDLK_Q, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RALT) == null);
}

test "test hook retains text and clipboard paths and maps paste" {
    try std.testing.expect(parseHook("resize:0x400") == null);
    try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text);
    try std.testing.expectEqualStrings("/tmp/source", parseHook("clipboard-set:/tmp/source").?.clipboard_set);
    const paste = parseHook("key:paste").?.key;
    try std.testing.expectEqual(c.SDLK_V, paste.code);
    try std.testing.expect(paste.mods & c.SDL_KMOD_CTRL != 0 and paste.mods & c.SDL_KMOD_SHIFT != 0);
}

// Caller owns Runtime/Wake and must supply window/cache before using their paths.
fn testEvents(rt: *runtime.Runtime, wake: *Wake, metrics: model.Metrics, width: i32, height: i32) Events {
    return .{ .win = undefined, .wake = wake, .hook = null, .cache = undefined, .base_font_px = 16, .ui = .{ .rt = rt, .metrics = metrics, .fb_w = width, .fb_h = height, .wake_ctx = wake, .wake = Wake.discovery } };
}

test "delayed End refusal cannot install a hidden force menu over an active picker" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 800, 600);
    defer events.deinit();
    try events.ui.relayout();
    events.ui.pending_end = .{ .key = rt.get(id).?.key, .request = 1 };
    try events.ui.open(.insert);
    rt.get(id).?.pump.mu.lock();
    rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
    rt.get(id).?.pump.mu.unlock();
    _ = rt.poll(std.time.milliTimestamp());
    try events.ui.pollEnd();
    try std.testing.expect(events.ui.recovery == null and events.ui.modalPicker() != null);
    try std.testing.expect(events.ui.pending_end == null);
    try events.ui.modalPicker().?.show(.session_name);
    var ev = std.mem.zeroes(c.SDL_Event);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_X;
    _ = try events.handle(ev);
    ev.text.type = c.SDL_EVENT_TEXT_INPUT;
    ev.text.text = "x";
    _ = try events.handle(ev);
    try std.testing.expectEqualStrings("x", events.ui.modalPicker().?.presentation.input.items);
    try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request);
}

test "delayed End refusal respects changed focus and recovery context names its captured pane" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const first = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics);
    const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics);
    const key = rt.get(first).?.key;
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 800, 600);
    defer events.deinit();
    try events.ui.relayout();
    events.ui.pending_end = .{ .key = key, .request = 1 };
    rt.get(first).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
    try events.ui.pollEnd();
    try std.testing.expectEqual(second, rt.workspace.tab().focus.?);
    try std.testing.expect(events.ui.recovery == null and events.ui.pending_end == null);

    // Returning to the target permits its explicit confirmation. Even if focus
    // later moves, the rendered context follows the menu's captured attachment.
    _ = rt.workspace.focus(first);
    events.ui.pending_end = .{ .key = key, .request = 1 };
    try events.ui.pollEnd();
    try std.testing.expectEqual(key, events.ui.recovery.?.key);
    try std.testing.expectEqual(@as(usize, 0), events.ui.recovery.?.selected);
    _ = rt.workspace.focus(second);
    var popup: PopupFrame = .{};
    popup.setRecovery(events.ui.recovery.?, &events);
    const context = &popup.lines[popup.len - 3];
    try std.testing.expectEqualStrings("end-target on cat", context.bytes[0..context.len]);
    try std.testing.expectEqualStrings("Cancel", popup.lines[1].bytes[0..popup.lines[1].len]);
    try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]);
}

test "held detach removes one pane and recovery Enter stays consumed after closing" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
    _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 800, 600);
    events.ui.command_mode = true;
    defer events.deinit();
    try events.ui.relayout();
    var ev = std.mem.zeroes(c.SDL_Event);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_D;
    _ = try events.handle(ev);
    ev.key.repeat = true;
    _ = try events.handle(ev);
    try std.testing.expectEqual(@as(usize, 1), events.ui.layout.len);
    try std.testing.expect(rt.get(first) != null);
    ev.key.type = c.SDL_EVENT_KEY_UP;
    _ = try events.handle(ev);
    events.ui.recovery = .{ .kind = .force_end, .key = rt.get(first).?.key };
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_RETURN;
    ev.key.repeat = false;
    _ = try events.handle(ev); // Cancel is the default action.
    ev.key.repeat = true;
    _ = try events.handle(ev);
    try std.testing.expect(events.ui.recovery == null and events.ui.pending_end == null and events.ui.suppress_text);
    try std.testing.expectEqual(client.session_pump.EndPhase.idle, rt.get(first).?.pump.state().ending.phase);
    // A narrow connecting pane must show save failure before its long label.
    rt.workspace.pane(first).?.identity.label = "very-long-host-and-session-label-that-would-otherwise-hide-the-save-error";
    const warning = "Workspace not saved: AccessDenied";
    @memcpy(events.ui.save_notice[0..warning.len], warning);
    events.ui.save_notice_len = warning.len;
    var placement = events.ui.layout.get(first).?;
    placement.header.w = 32 * metrics.cell_w;
    var header: Header = .{};
    header.set(&events, placement, rt.get(first).?);
    try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:"));
}

test "pending End header stays with its origin and rejects stale generations" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
    const second = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 800, 600);
    defer events.deinit();
    try events.ui.relayout();
    const key = rt.get(first).?.key;
    events.ui.pending_end = .{ .key = key, .request = 1 };
    _ = rt.workspace.focus(second);
    var origin_header: Header = .{};
    origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?);
    try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") != null);
    var other_header: Header = .{};
    other_header.set(&events, events.ui.layout.get(second).?, rt.get(second).?);
    try std.testing.expect(std.mem.indexOf(u8, other_header.bytes[0..other_header.len], "End requested") == null);
    events.ui.pending_end.?.key.generation += 1;
    origin_header.set(&events, events.ui.layout.get(first).?, rt.get(first).?);
    try std.testing.expect(std.mem.indexOf(u8, origin_header.bytes[0..origin_header.len], "End requested") == null);
    events.ui.pending_end = null;
}

fn mouseButton(button: u8) ?u8 {
    return switch (button) {
        c.SDL_BUTTON_LEFT => 0,
        c.SDL_BUTTON_MIDDLE => 1,
        c.SDL_BUTTON_RIGHT => 2,
        else => null,
    };
}
fn mouseMods() input.Mods {
    const mods = c.SDL_GetModState();
    return .{ .shift = mods & c.SDL_KMOD_SHIFT != 0, .ctrl = mods & c.SDL_KMOD_CTRL != 0, .alt = mods & c.SDL_KMOD_ALT != 0 };
}
fn setClipboard(ui: *interaction.Controller, text: []const u8, primary: bool) !void {
    if (!client.core.validClipboardText(text)) return;
    const z = try ui.rt.alloc.dupeZ(u8, text);
    defer ui.rt.alloc.free(z);
    const ok = if (primary) c.SDL_SetPrimarySelectionText(z.ptr) else c.SDL_SetClipboardText(z.ptr);
    if (!ok) ui.setNotice("Clipboard update failed");
}

const Header = struct {
    bytes: [512]u8 = undefined,
    cells: [512]term.grid.Cell = undefined,
    len: usize = 0,
    fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
        const focused = events.ui.rt.workspace.tab().focus == p.id;
        const pending = events.ui.rt.workspace.tab().pending;
        const ending = if (events.ui.pending_end) |end| end.key.pane == p.id and events.ui.rt.accepts(end.key) else false;
        const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick;
        const hint = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (ending) " [End requested; waiting for daemon]" else if (focused and events.ui.resize_mode) (if (events.ui.notice.len != 0) events.ui.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.ui.command_mode) " [command: f fullscreen, v new below, b new beside, h/j/k/l focus, r resize, Enter picks session, Esc cancel]" else if (quick) events.ui.opening.?.noticeText() else if (pending != null and pending.?.pane == p.id) (if (events.ui.modalPicker() != null and events.ui.modalPicker().?.mode == .insert) (if (pending.?.direction == .beside) " [split beside]" else " [split below]") else (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]")) else if (events.ui.notice.len != 0) events.ui.notice else if (events.ui.rt.workspace.tab().fullscreen) " [fullscreen]" else "";
        const label = events.ui.rt.workspace.pane(p.id).?.identity.label;
        var status_buf: [48]u8 = undefined;
        const status: []const u8 = switch (live.status.phase) {
            .attached => "",
            .dialing => "[connecting] ",
            .reconnecting => "[reconnecting] ",
            .dial_failed => "[unreachable] ",
            .failed => "[failed] ",
            .refused => "[refused] ",
            .taken => "[taken by another client] ",
            .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable,
        };
        const text = if (events.ui.save_notice_len != 0)
            std.fmt.bufPrint(&self.bytes, "{s}{s} {s}{s}", .{ if (focused) "> " else "  ", hint, status, label }) catch self.bytes[0..]
        else
            std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else "  ", status, hint, label }) catch self.bytes[0..];
        self.setText(text, p.header.w / events.ui.metrics.cell_w);
    }
    fn setText(self: *Header, text: []const u8, cols: usize) void {
        const n = @min(text.len, self.bytes.len);
        std.mem.copyForwards(u8, &self.bytes, text[0..n]);
        self.len = 0;
        var offset: usize = 0;
        while (offset < n and self.len < @min(cols, self.cells.len)) {
            const length = std.unicode.utf8ByteSequenceLength(self.bytes[offset]) catch 1;
            if (offset + length > n) break;
            if (self.bytes[offset] < 0x20 or self.bytes[offset] == 0x7f) self.bytes[offset] = '?';
            self.cells[self.len] = .{ .text_off = @intCast(offset), .text_len = length };
            self.len += 1;
            offset += length;
        }
    }
    fn row(self: *Header) term.grid.Row {
        return .{ .cells = self.cells[0..self.len], .text = .{ .items = &self.bytes, .capacity = 0 } };
    }
};
fn prepareRow(cache: *font.GlyphCache, row: *const term.grid.Row) !void {
    for (row.cells) |cell| {
        if (cell.text_len == 0 or cell.wide == .spacer_tail) continue;
        try cache.prepare(row.textOf(cell), quads.variantOf(cell.style.flags));
    }
}
fn prepareGrid(cache: *font.GlyphCache, grid: *const term.grid.Grid) !void {
    for (grid.lines) |*row| try prepareRow(cache, row);
}
fn clipPane(lists: *quads.Lists, bg: usize, fg: usize, rect: model.Rect) void {
    quads.clip(&lists.backgrounds, bg, @floatFromInt(rect.x), @floatFromInt(rect.y), @floatFromInt(rect.w), @floatFromInt(rect.h));
    quads.clip(&lists.foregrounds, fg, @floatFromInt(rect.x), @floatFromInt(rect.y), @floatFromInt(rect.w), @floatFromInt(rect.h));
}
fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void {
    // Called before capture/rebuild, without forcing dirty. Snapshot and sequence
    // are those of the last completed draw, rather than a newer live replica.
    var arena = std.heap.ArenaAllocator.init(alloc);
    defer arena.deinit();
    const a = arena.allocator();
    const PaneState = struct {
        id: model.PaneId,
        generation: u64,
        label: []const u8,
        outer: model.Rect,
        header: model.Rect,
        content: model.Rect,
        visible: model.Rect,
        cols: u16,
        rows: u16,
        phase: []const u8,
        exit_code: u8,
        painted_seq: u64,
        painted_text: []const u8,
    };
    var panes: [model.max_panes]PaneState = undefined;
    for (events.ui.layout.items(), 0..) |p, i| {
        const live = events.ui.rt.get(p.id).?;
        panes[i] = .{ .id = p.id, .generation = live.key.generation, .label = events.ui.rt.workspace.pane(p.id).?.identity.label, .outer = p.outer, .header = p.header, .content = p.content, .visible = p.visible, .cols = p.cols, .rows = p.rows, .phase = @tagName(live.status.phase), .exit_code = live.status.exit_code, .painted_seq = live.painted_seq, .painted_text = if (live.painted_seq == 0) "" else try live.snapshot.dumpPlain(a) };
    }
    var w: c_int = 0;
    var h: c_int = 0;
    _ = c.SDL_GetWindowSize(events.win, &w, &h);
    const PopupRow = struct { label: []const u8, rect: model.Rect };
    const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize };
    var picker_state: ?PopupState = null;
    if (events.ui.modalPicker()) |picker| {
        const view = picker.layout();
        const rows = try a.alloc(PopupRow, picker.presentation.owned.rows.items.len);
        for (rows, 0..) |*row, i| row.* = .{ .label = picker.presentation.owned.rows.items[i].label, .rect = .{ .x = view.rowRect(i).x, .y = view.rowRect(i).y, .w = view.rowRect(i).w, .h = view.rowRect(i).h } };
        picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.presentation.selected orelse 0, .notice = picker.noticeText(), .host = picker.host(), .input = picker.presentation.input.items, .rect = .{ .x = view.rect.x, .y = view.rect.y, .w = view.rect.w, .h = view.rect.h }, .row_height = view.row_height, .first = view.first };
    }
    const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect };
    var recovery_state: ?RecoveryState = null;
    if (events.ui.recovery) |menu| {
        const view = menu.view(@intCast(@max(events.ui.fb_w, 0)), @intCast(@max(events.ui.fb_h, 0)), events.ui.metrics);
        const rows = try a.alloc(PopupRow, menu.count());
        for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) };
        recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect };
    }
    const quick = events.ui.opening != null and events.ui.opening.?.mode == .quick;
    const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.ui.fb_w, .height = events.ui.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.ui.metrics.cell_w, .cell_h = events.ui.metrics.cell_h, .divider = events.ui.metrics.divider, .header_h = events.ui.metrics.cell_h, .tab = events.ui.rt.workspace.active_tab_id, .focus = events.ui.rt.workspace.tab().focus, .pending = events.ui.rt.workspace.tab().pending, .command_mode = events.ui.command_mode, .resize_mode = events.ui.resize_mode, .drag = events.ui.drag, .dividers = events.ui.layout.boundaries(), .notice = if (events.ui.save_notice_len != 0) events.ui.save_notice[0..events.ui.save_notice_len] else if (quick) events.ui.opening.?.noticeText() else events.ui.notice, .persistent = events.ui.store != null, .save_enabled = if (events.ui.store) |store| store.writable else false, .pending_end = events.ui.pending_end, .recovery = recovery_state, .picker = picker_state, .opening = quick, .panes = panes[0..events.ui.layout.len] }, .{});
    try writeArtifact(a, path, &.{bytes});
}
fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void {
    var header: [64]u8 = undefined;
    try writeArtifact(alloc, path, &.{ try std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }), pixels });
}
fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void {
    const temporary = try std.fmt.allocPrint(alloc, "{s}.tmp", .{path});
    defer alloc.free(temporary);
    const file = try std.fs.cwd().createFile(temporary, .{});
    defer file.close();
    errdefer std.fs.cwd().deleteFile(temporary) catch {};
    for (parts) |bytes| try file.writeAll(bytes);
    try std.fs.cwd().rename(temporary, path);
}

test "logical pointer coordinates use drawable density independently of font scale" {
    const p = physicalPoint(480, 300, 960, 600, 1920, 1200);
    try std.testing.expectEqual(@as(u32, 960), p.x);
    try std.testing.expectEqual(@as(u32, 600), p.y);
    // A content scale of two with a density of one must keep these unchanged.
    try std.testing.expectEqual(@as(u32, 480), physicalPoint(480, 300, 960, 600, 960, 600).x);
    try std.testing.expectEqual(std.math.maxInt(u32), physicalAxis(-1, 960, 1920));
    try std.testing.expectEqual(@as(i64, -20), physicalSignedAxis(-10, 960, 1920).?);
    try std.testing.expect(physicalSignedAxis(std.math.nan(f32), 960, 1920) == null);
    try std.testing.expectEqual(@as(u32, 12), grabPixels(960, 1920));
    try std.testing.expectEqual(@as(u32, 6), grabPixels(960, 960));
}

test "resize mode repeats move one cell without leaking held directions after exit" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
    _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 801, 601);
    defer events.deinit();
    try events.ui.relayout();
    try events.ui.command(.r);
    var ev = std.mem.zeroes(c.SDL_Event);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_H;
    _ = try events.handle(ev);
    ev.key.repeat = true;
    _ = try events.handle(ev);
    const divider = events.ui.layout.boundaries()[0];
    try std.testing.expectEqual(@as(u32, 380), divider.position());
    ev.key.repeat = false;
    ev.key.key = c.SDLK_RETURN;
    _ = try events.handle(ev);
    try std.testing.expect(!events.ui.resize_mode);
    ev.key.key = c.SDLK_H;
    ev.key.repeat = true;
    _ = try events.handle(ev);
    try std.testing.expect(events.ui.suppress_text);
    try std.testing.expectEqual(divider.rect, events.ui.layout.boundaries()[0].rect);
    ev.key.type = c.SDL_EVENT_KEY_UP;
    _ = try events.handle(ev);
    try std.testing.expect(!events.ui.modal_held.contains(c.SDLK_H));
    // Physical aliases and ordinary modal text each retain their own release.
    try events.ui.command(.r);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.repeat = false;
    ev.key.key = c.SDLK_H;
    _ = try events.handle(ev);
    ev.key.key = c.SDLK_LEFT;
    _ = try events.handle(ev);
    ev.key.type = c.SDL_EVENT_KEY_UP;
    _ = try events.handle(ev);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_X;
    _ = try events.handle(ev);
    ev.key.key = c.SDLK_ESCAPE;
    _ = try events.handle(ev);
    for ([_]u32{ c.SDLK_H, c.SDLK_X }) |held| {
        ev.key.key = held;
        ev.key.repeat = true;
        _ = try events.handle(ev);
        try std.testing.expect(events.ui.suppress_text);
        ev.key.type = c.SDL_EVENT_KEY_UP;
        _ = try events.handle(ev);
        ev.key.type = c.SDL_EVENT_KEY_DOWN;
    }
    const resized = events.ui.layout.boundaries()[0];
    try events.ui.command(.r);
    events.ui.fb_w = 8;
    events.ui.fb_h = 8;
    try events.ui.relayout();
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_RIGHT;
    _ = try events.handle(ev);
    try std.testing.expectEqualStrings("Window too small to resize", events.ui.notice);
    events.ui.fb_w = 801;
    events.ui.fb_h = 601;
    try events.ui.relayout();
    try std.testing.expectEqual(resized.rect, events.ui.layout.boundaries()[0].rect);
}

test "divider drag preserves grab offset clamps signed outside motion and cancels on focus loss" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
    _ = try rt.add(.{ .via = "cat" }, "left", 801, 601, metrics);
    _ = try rt.add(.{ .via = "cat" }, "right", 801, 601, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 801, 601);
    defer events.deinit();
    try events.ui.relayout();
    try events.ui.pointerDown(402, 100, 6, 6);
    try std.testing.expect(events.ui.drag != null);
    try events.ui.pointerMove(402, 100);
    try std.testing.expectEqual(@as(u32, 400), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerMove(452, 100);
    try std.testing.expectEqual(@as(u32, 450), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerMove(-100, 100);
    try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
    var ev = std.mem.zeroes(c.SDL_Event);
    ev.type = c.SDL_EVENT_WINDOW_FOCUS_LOST;
    _ = try events.handle(ev);
    try std.testing.expect(events.ui.drag == null);
    try events.ui.pointerMove(600, 100);
    try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerDown(20, 100, 6, 6);
    try events.ui.command(.r);
    try std.testing.expect(events.ui.drag == null);
    try events.ui.pointerMove(600, 100);
    try std.testing.expectEqual(@as(u32, 20), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerDown(20, 100, 6, 6);
    ev.key.type = c.SDL_EVENT_KEY_DOWN;
    ev.key.key = c.SDLK_RIGHT;
    _ = try events.handle(ev);
    try std.testing.expect(events.ui.drag == null);
    try std.testing.expectEqual(@as(u32, 30), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerMove(20, 100);
    try std.testing.expectEqual(@as(u32, 30), events.ui.layout.boundaries()[0].position());
    try events.ui.pointerDown(30, 100, 6, 6);
    ev.key.key = c.SDLK_ESCAPE;
    _ = try events.handle(ev);
    try std.testing.expect(events.ui.drag == null and !events.ui.resize_mode);
}

test "multi-pane scale transitions resize every content claim and preserve stable attachment keys" {
    const a = std.testing.allocator;
    var fonts = try font.FontSet.openDefault(a, 16);
    defer fonts.deinit(a);
    var glyph_atlas = try atlas.Atlas.init(a, font.atlasWidth(16), 256);
    defer glyph_atlas.deinit(a);
    var cache: font.GlyphCache = .{ .alloc = a, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
    defer cache.deinit();
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics = measuredMetrics(fonts.primary(), 1);
    const first = try rt.add(.{ .via = "cat" }, "left", 960, 600, metrics);
    rt.workspace.arm(.beside);
    const second = try rt.add(.{ .via = "cat" }, "right", 960, 600, metrics);
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 960, 600);
    events.cache = &cache;
    defer events.deinit();
    try events.ui.relayout();
    const initial = events.ui.layout;
    const boundary = initial.boundaries()[0];
    try events.ui.pointerDown(boundary.rect.x, 100, 6, 6);
    try std.testing.expect(events.ui.drag != null);
    const old_key = rt.get(first).?.key;
    var event = std.mem.zeroes(c.SDL_Event);
    event.type = c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.geometry_dirty);
    try events.updateGeometry(960, 600, 2);
    try std.testing.expect(events.ui.drag == null);
    for (events.ui.layout.items(), initial.items()) |p, before| {
        try std.testing.expect(p.cols < before.cols and p.rows < before.rows);
        try std.testing.expectEqual(p.cols, rt.get(p.id).?.size.cols);
        try std.testing.expectEqual(p.rows, rt.get(p.id).?.size.rows);
    }
    try events.updateGeometry(1920, 1200, 2);
    for (events.ui.layout.items(), initial.items()) |p, before| {
        try std.testing.expect(@abs(@as(i32, p.cols) - before.cols) <= @max(@as(u32, before.cols) / 8, 1));
        try std.testing.expect(@abs(@as(i32, p.rows) - before.rows) <= @max(@as(u32, before.rows) / 8, 1));
        try std.testing.expectEqual(@as(u32, fonts.primary().cell_h), p.content.y);
    }
    try cache.prepare("M", .regular);
    try events.updateGeometry(1600, 1000, 2.001);
    try std.testing.expectEqual(@as(usize, 1), cache.runs.count());
    try std.testing.expectEqual(@as(u16, 32), fonts.primary().pixels);
    try events.updateGeometry(960, 600, 1);
    try std.testing.expectEqualDeep(initial.items(), events.ui.layout.items());
    try std.testing.expect(rt.accepts(old_key));
    // Removal joins before destroying the identity/context. A queued old key
    // cannot select a new slot occupant, even after a further insertion.
    rt.remove(first);
    try std.testing.expect(!rt.accepts(old_key));
    rt.workspace.arm(.stacked);
    const third = try rt.add(.{ .via = "cat" }, "third", 960, 600, metrics);
    try std.testing.expect(third != first and third != second);
    try std.testing.expect(!rt.accepts(old_key));
}

test "command key text is consumed and subsequent ordinary text is not suppressed" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    // No live attachment is needed to test the actual SDL event routing state.
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, .{ .cell_w = 8, .cell_h = 16 }, 960, 600);
    defer events.deinit();
    var event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_BACKSLASH;
    for ([_]u16{ 0, c.SDL_KMOD_SHIFT, c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT, c.SDL_KMOD_CTRL | c.SDL_KMOD_GUI, c.SDL_KMOD_CTRL | c.SDL_KMOD_MODE }) |mods| {
        event.key.mod = mods;
        try std.testing.expect(try events.handle(event));
        try std.testing.expect(!events.ui.command_mode);
    }
    // Bare Ctrl activates the prefix; Shift is also accepted below.
    event.key.mod = c.SDL_KMOD_CTRL;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.command_mode and events.ui.suppress_text);
    event.text.type = c.SDL_EVENT_TEXT_INPUT;
    event.text.text = "\\";
    try std.testing.expect(try events.handle(event));
    event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_H;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text);
    event.text.type = c.SDL_EVENT_TEXT_INPUT;
    event.text.text = "h";
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.suppress_text);
    event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_H;
    event.key.repeat = true;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.suppress_text);
    event.key.type = c.SDL_EVENT_KEY_UP;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.consumed_key == null);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.repeat = false;
    event.key.key = c.SDLK_X;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.suppress_text);
    // Repeated prefix exits mode; its shared input encoding is 0x1c.
    // The integration fixture verifies the emitted byte count with a real PTY.
    event.key.key = c.SDLK_BACKSLASH;
    event.key.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.command_mode);
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.command_mode);
    var bytes: [input.max_seq_len]u8 = undefined;
    try std.testing.expectEqualSlices(u8, &.{0x1c}, input.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes));
}

test "copy and paste shortcuts are platform-specific and exact; plain Ctrl stays terminal input" {
    for ([_]struct { key: u32, shortcut: NativeShortcut }{
        .{ .key = c.SDLK_C, .shortcut = .copy },
        .{ .key = c.SDLK_V, .shortcut = .paste },
    }) |case| {
        for ([_]bool{ false, true }) |macos| {
            var event = std.mem.zeroes(c.SDL_KeyboardEvent);
            event.key = case.key;
            for ([_]u16{ c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| {
                event.mod = mods;
                try std.testing.expectEqual(case.shortcut, nativeShortcut(event, macos).?);
                for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| {
                    event.mod = mods | extra;
                    try std.testing.expect(nativeShortcut(event, macos) == null);
                }
                event.mod = mods;
                event.repeat = true;
                try std.testing.expect(nativeShortcut(event, macos) == null);
                event.repeat = false;
            }
            event.mod = c.SDL_KMOD_CTRL;
            try std.testing.expect(nativeShortcut(event, macos) == null);
            try std.testing.expect(interactionKey(event).terminal != null);
            event.mod = c.SDL_KMOD_GUI;
            const gui_shortcut: ?NativeShortcut = if (macos and case.shortcut == .paste) .paste else null;
            try std.testing.expectEqual(gui_shortcut, nativeShortcut(event, macos));
        }
    }
}

test "later pane atlas growth precedes earlier pane UV generation" {
    const a = std.testing.allocator;
    var fonts = try font.FontSet.openDefault(a, 16);
    defer fonts.deinit(a);
    var glyph_atlas = try atlas.Atlas.init(a, 128, 1);
    defer glyph_atlas.deinit(a);
    var cache: font.GlyphCache = .{ .alloc = a, .fonts = &fonts, .glyph_atlas = &glyph_atlas };
    defer cache.deinit();
    const left = try term.grid.Grid.init(a, 1, 1);
    defer left.deinit();
    try left.lines[0].text.appendSlice(a, "M");
    left.lines[0].cells[0] = .{ .text_len = 1 };
    const right = try term.grid.Grid.init(a, 94, 1);
    defer right.deinit();
    for (right.lines[0].cells, 0..) |*cell, i| {
        try right.lines[0].text.append(a, @intCast(33 + i));
        cell.* = .{ .text_off = @intCast(i), .text_len = 1, .style = .{ .flags = 3 } };
    }
    try prepareGrid(&cache, left);
    const before = glyph_atlas.height;
    try prepareGrid(&cache, right);
    try std.testing.expect(glyph_atlas.height > before);
    const shaped = try font.GlyphCache.resolve(@ptrCast(&cache), "M", .regular);
    const entry = shaped[0].entry;
    const primary = fonts.primary();
    const ctx: quads.Ctx = .{ .cell_w = primary.cell_w, .cell_h = primary.cell_h, .ascent = primary.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve } };
    var lists: quads.Lists = .{};
    defer lists.deinit(a);
    _ = try quads.rowInstances(&lists, a, left.row(0), 1, 0, 0, ctx);
    try std.testing.expect(lists.foregrounds.items.len > 0);
    const glyph = lists.foregrounds.items[0];
    const expected = @as(f32, @floatFromInt(entry.y)) / @as(f32, @floatFromInt(glyph_atlas.height));
    try std.testing.expectApproxEqAbs(expected, glyph.v0, 0.00001);
    try std.testing.expect(glyph.v1 <= @as(f32, @floatFromInt(entry.y + entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height)));
}

const PopupFrame = struct {
    lines: [44]Header = @splat(.{}),
    len: usize = 0,
    rect: model.Rect = .{},
    selected_line: ?usize = null,
    fn reset(self: *PopupFrame, rect: model.Rect, len: usize, cell_w: u16) u32 {
        self.rect = rect;
        self.len = len;
        self.selected_line = null;
        const cols = rect.w / cell_w;
        for (self.lines[0..len]) |*line| line.setText("", cols);
        return cols;
    }
    fn setRecovery(self: *PopupFrame, menu: Recovery, events: *Events) void {
        const view = menu.view(@intCast(@max(events.ui.fb_w, 0)), @intCast(@max(events.ui.fb_h, 0)), events.ui.metrics);
        const cols = self.reset(view.rect, menu.count() + 4, events.ui.metrics.cell_w);
        self.selected_line = menu.selected + 1;
        self.lines[0].setText(menu.title(), cols);
        for (0..menu.count()) |i| self.lines[i + 1].setText(menu.label(i), cols);
        if (events.ui.rt.accepts(menu.key)) {
            const identity = &events.ui.rt.workspace.pane(menu.key.pane).?.identity;
            const host = switch (identity.target) {
                .sock, .via => |text| text,
                .quic => |target| target.host_port,
                .hand => |target| target.host,
            };
            var context: [512]u8 = undefined;
            const text = std.fmt.bufPrint(&context, "{s} on {s}", .{ identity.session, host[0..@min(host.len, context.len - identity.session.len - 4)] }) catch unreachable;
            self.lines[self.len - 3].setText(text, cols);
        } else self.lines[self.len - 3].setText("The pane changed", cols);
        self.lines[self.len - 2].setText(menu.notice[0..menu.notice_len], cols);
        self.lines[self.len - 1].setText("Up/Down or j/k choose | Enter selects | Esc closes", cols);
    }
    fn setEmpty(self: *PopupFrame, events: *Events) void {
        const width: u32 = @intCast(@max(events.ui.fb_w, 0));
        const height: u32 = @intCast(@max(events.ui.fb_h, 0));
        const w = @min(width, @as(u32, events.ui.metrics.cell_w) * 74);
        const h = @min(height, @as(u32, events.ui.metrics.cell_h) * 4);
        const cols = self.reset(.{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, 4, events.ui.metrics.cell_w);
        self.selected_line = 1;
        self.lines[0].setText("No panes", cols);
        self.lines[1].setText("Add pane", cols);
        self.lines[2].setText(events.ui.notice, cols);
        self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols);
    }
    fn setPopover(self: *PopupFrame, layout_: popover.Layout, view: popover.Presentation) void {
        const cols = self.reset(.{ .x = layout_.rect.x, .y = layout_.rect.y, .w = layout_.rect.w, .h = layout_.rect.h }, layout_.shown + 4, layout_.cell_width);
        self.lines[0].setText(view.title, cols);
        for (0..layout_.shown) |i| {
            const index = layout_.first + i;
            self.lines[i + 1].setText(view.rows[index].label, cols);
            if (view.selected == index) self.selected_line = i + 1;
        }
        if (view.editor) |editor| {
            // Keep the caret end visible while preserving the complete editor
            // buffer; the actual selection/name does not truncate with a row.
            var start = editor.len -| cols;
            while (start < editor.len and editor[start] & 0xc0 == 0x80) start += 1;
            self.lines[1].setText(editor[start..], cols);
            self.selected_line = 1;
        } else self.lines[self.len - 3].setText(view.context, cols);
        self.lines[self.len - 2].setText(view.notice, cols);
        self.lines[self.len - 1].setText(view.hint, cols);
    }
    fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void {
        if (self.len == 0) return;
        const bg_start = lists.backgrounds.items.len;
        const fg_start = lists.foregrounds.items.len;
        try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), base.theme.modal_bg));
        for (self.lines[0..self.len], 0..) |*line, i| {
            var ctx = base;
            ctx.x0 = @floatFromInt(self.rect.x);
            ctx.y0 = @floatFromInt(self.rect.y + @as(u32, @intCast(i)) * base.cell_h);
            const selected = self.selected_line == i;
            ctx.fg = if (selected) base.theme.modal_selected_fg else base.theme.modal_fg;
            ctx.bg = if (selected) base.theme.modal_selected_bg else base.theme.modal_bg;
            if (selected) try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(self.rect.w), @floatFromInt(base.cell_h), base.theme.modal_selected_bg));
            var row = line.row();
            _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx);
        }
        clipPane(lists, bg_start, fg_start, self.rect);
    }
};

test "modal Enter remains consumed through insertion and repeated keydown until release" {
    const a = std.testing.allocator;
    var rt = runtime.Runtime.init(a, .{});
    defer rt.deinit();
    const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
    const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
    rt.workspace.arm(.beside);
    var next: u64 = 2;
    const picker = try a.create(picker_mod.Picker);
    picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null, .presentation = try popover.State.init(a, .{}) };
    var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
    var events = testEvents(&rt, &wake, metrics, 800, 600);
    events.ui.opening = picker;
    defer events.deinit();
    try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
    try picker.show(.session_name);
    var event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_J;
    try std.testing.expect(try events.handle(event));
    event.text.type = c.SDL_EVENT_TEXT_INPUT;
    event.text.text = "j";
    try std.testing.expect(try events.handle(event));
    try std.testing.expectEqualStrings("j", picker.presentation.input.items);
    // Modifier chords belong to the modal, even double-prefix. Neither the
    // editor nor the terminal receives its associated text.
    event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_BACKSLASH;
    event.key.mod = c.SDL_KMOD_CTRL;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.command_mode and events.ui.suppress_text);
    picker.session_count = 1;
    picker.sessions[0][0] = 'x';
    picker.session_lens[0] = 1;
    try picker.show(.sessions);
    event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_RETURN;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.modalPicker() == null);
    try std.testing.expectEqual(@as(usize, 2), events.ui.layout.len);
    event.key.repeat = true;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.suppress_text and events.ui.consumed_key == c.SDLK_RETURN);
    event.key.type = c.SDL_EVENT_KEY_UP;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(events.ui.consumed_key == null);
    event = std.mem.zeroes(c.SDL_Event);
    event.key.type = c.SDL_EVENT_KEY_DOWN;
    event.key.key = c.SDLK_X;
    try std.testing.expect(try events.handle(event));
    try std.testing.expect(!events.ui.suppress_text);
}