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