src/tui/interact.zig
Ref: Size: 219.5 KiB History
//! What happens between a user at a terminal and one attached session: the
//! `Ctrl-\` chord layer, the mouse/wheel splitter and alternate scroll,
//! speculative echo, the side channels a session drives on the host terminal,
//! and the terminal ownership those depend on.
//!
//! Not here: how a transport is BUILT (client.zig) or what a chord MEANS —
//! `PrefixFilter` says which action was typed and each driver decides what it
//! does. The transport is `anytype`, naming only `writeFrame`, so this loop
//! is transport-blind and testable with no dial behind it.
//!
//! Keeper of two CLAUDE.md invariants: prediction never enters the replica,
//! and `replica.zig` is the one applier.
const std = @import("std");
const grid_mod = @import("term").grid;
const Grid = grid_mod.Grid;
const Replica = @import("term").replica.Replica;
const proto = @import("term").protocol;
const predict = @import("predict.zig");
const client_core = @import("client").core;
const client_os = @import("client_os");
// Named `paint_mod` because paintOverlay holds a local ArrayList called
// `paint`, which a container-level `paint` would collide with.
const paint_mod = @import("paint.zig");
const askpass = @import("client").askpass;
const select = @import("client").selection;
// The command prefix byte also cancels a dial, so client owns it rather than
// either surface.
const detach_key = @import("client").interrupt.detach_key;
/// What `select` answers, in the shape `paint` asks for. Selection policy lives
/// in `client.selection`; this adapter converts its absolute rows to the wall's
/// grid rows before painting.
///
/// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter
/// speaks grid rows, so the history count of the frame being painted turns one
/// into the other — read from the replica the paint is walking, or the
/// highlight lands on another frame's lines.
pub const Highlight = struct {
drag: *const select.Drag,
/// Which tile the drag belongs to. Zero for a focused `Core`, which is
/// the only session on its screen; the wall passes the real index.
tile: usize,
history_rows: u32,
/// A paint with no selection on it asks nothing per row and emits
/// exactly the bytes it emitted before this feature existed.
pub fn sink(self: *const Highlight) paint_mod.Highlight {
if (self.drag.range() == null) return .{};
return .{ .ctx = @constCast(self), .span = &spanOf };
}
fn spanOf(ctx: ?*anyopaque, row: u16, cols: u16) ?paint_mod.Span {
const self: *const Highlight = @ptrCast(@alignCast(ctx.?));
const s = self.drag.span(self.tile, self.history_rows + row, cols) orelse return null;
return .{ .from = s.from, .to = s.to };
}
};
/// The attached client's keybinding layer: `Ctrl-\` selects a command rather
/// than acting on its own. `d` or a second `Ctrl-\` detach, `c` creates a
/// session, `n`/`p` step, `l` skips to the last visited, `w` shows the wall;
/// any other key is dropped with the prefix.
///
/// Only what is typed at an ESTABLISHED session passes through: keystrokes
/// `attach` carried across the handshake go out as input, since there was no
/// session to command yet. Public so the wall's focused tile shares the one
/// table — what each action MEANS is the caller's, which byte spells it is not.
pub const PrefixFilter = struct {
pub const Dir = enum { left, down, up, right };
/// The picker's two lists: the hosts file's daemons, and one daemon's
/// own sessions.
pub const PickLevel = enum { hosts, sessions };
/// Callers switch on it, so a new variant is additive.
pub const Action = union(enum) {
none,
detach,
new_session,
next_session,
prev_session,
wall,
// The digit pressed (1-9): `Ctrl-\ 3` focuses tile 3. Four bits,
// because nine values do not fit in three.
focus: u4,
/// `Ctrl-\ x`: take the focused pane off THIS wall. The session is
/// left running on its daemon for whoever else holds it, so the key
/// is reversible — the picker puts the pane back.
remove_pane,
/// `Ctrl-\ X`: ask the focused pane's own daemon to END its session.
/// The shift is the whole difference between the reversible key and
/// the one that kills a shell, which is why they are two actions and
/// not one with a flag.
end_focused,
focus_dir: Dir,
split_right,
split_below,
fullscreen,
resize: Dir,
/// The picker's `a`, then a line and Enter: a host spelling to add
/// to the wall. Borrows the filter's buffer until the next feed.
add_tile: []const u8,
/// `Ctrl-\ s`: open the host picker. The wall shows live sessions,
/// so a host with none has nowhere else to be named.
pick_open,
/// One row up (-1) or down (+1) in the picker.
pick_move: i8,
/// The digit pressed (1-9): the picker's row, not the wall's tile.
pick_select: u4,
pick_birth,
pick_forget,
pick_add_open,
pick_close,
/// Enter. At the host level it opens that host's session list; at
/// the session level it chooses a session and closes the popup.
/// One action, because the filter has already flipped `pick_level`
/// by the time the caller reads it — the NEW level says which of
/// the two just happened.
pick_enter,
/// Esc at the session level: back to the hosts, popup still up.
pick_back,
/// `x` at the session level: end that session on its daemon.
pick_end,
/// ssh asked something and the user answered. Borrows the filter's
/// buffer until the next feed, as `add_tile` does. An EMPTY answer
/// is an answer: a key whose passphrase is empty is a real key.
ask_answer: []const u8,
/// Esc or Ctrl-C on a prompt. ssh reads it as a refusal and gives
/// up, which is what the user just said.
ask_decline,
};
pub const Out = struct { forward: []const u8, action: Action };
/// A sun_path is at most 108 bytes and the hostnames people type are
/// short, so 256 holds every spelling the grammar accepts in practice.
/// A longer one is cut at the cap, not refused.
pub const prompt_max: usize = 256;
// The banner buffer must hold a whole prompt line — the `": "` and the
// cursor `"_"` on top of the spelling — and a comment cannot fail a
// build. Asserted here because the import only runs this way: interact
// imports paint, never the reverse.
comptime {
std.debug.assert(prompt_max + ": ".len + "_".len <= paint_mod.banner_label_max);
}
/// A prefix arrived at the end of a read and its command key has not
/// been typed yet. Held across reads so a chord split by a read
/// boundary is still one chord.
pending: bool = false,
/// `Ctrl-\ r` enters resize mode: bare hjkl trade cells without a
/// fresh prefix. Esc leaves silently; any other byte leaves and is
/// processed normally — the mode never eats prose.
resizing: bool = false,
/// The picker's `a` opens the prompt: a line editor for one spelling.
/// Every byte is the prompt's until Enter or Esc — the mode eats prose
/// on purpose, unlike resize mode. A layer OVER `picking`, not a mode
/// beside it: both ends of the editor go back to the rows.
prompting: bool = false,
line: [prompt_max]u8 = undefined,
line_len: usize = 0,
/// `Ctrl-\ s` opens the host picker, and every byte is the popup's
/// until it closes — so a `j` aimed at the rows can never reach a
/// shell. The driver paints; this owns which key means what.
picking: bool = false,
/// Which list the popup shows. The filter owns it because Enter and Esc
/// mean different things at each level, and a key must resolve to ONE
/// action without the wall's help.
pick_level: PickLevel = .hosts,
/// ssh is waiting on an answer. Over EVERYTHING, `picking` included: a
/// prompt is not a mode the user chose, it arrived. Every byte is the
/// popup's — a `j` typed at a password box must not reach a shell.
asking: bool = false,
/// What ssh said it was asking for. The filter carries it to the
/// painter and decides nothing: a secret is starred, a confirmation is
/// read back by the user, and a notice takes no answer at all.
kind: askpass.Kind = .secret,
/// Its OWN buffer, not `line`: a prompt can arrive while the picker's
/// spelling editor holds a half-typed host, and sharing one buffer
/// would eat that line to answer a question about another machine.
ask_line: [askpass.answer_max]u8 = undefined,
ask_len: usize = 0,
pub fn askLine(self: *const PrefixFilter) []const u8 {
return self.ask_line[0..self.ask_len];
}
/// Whether the answer is painted behind stars.
pub fn masks(self: *const PrefixFilter) bool {
return self.kind == .secret;
}
pub fn askOpen(self: *PrefixFilter, kind: askpass.Kind) void {
self.asking = true;
self.kind = kind;
self.ask_len = 0;
@memset(&self.ask_line, 0);
}
/// Called by the driver once the answer has been handed on.
pub fn askClose(self: *PrefixFilter) void {
// Which is what makes the zeroing a definite point: `feed` cannot
// do it — the action it returns borrows this buffer.
self.asking = false;
self.ask_len = 0;
@memset(&self.ask_line, 0);
}
pub fn promptLine(self: *const PrefixFilter) []const u8 {
return self.line[0..self.line_len];
}
/// An up/down arrow at the head of `tail`, in both forms a terminal
/// sends it: `[A` after the Esc, `OA` in application cursor mode.
fn arrowMove(tail: []const u8) ?i8 {
if (tail.len < 2) return null;
if (tail[0] != '[' and tail[0] != 'O') return null;
return switch (tail[1]) {
'A' => -1,
'B' => 1,
else => null,
};
}
/// Filters one raw stdin chunk in place — the layer only removes bytes, so
/// survivors compact leftwards over the same buffer. An ACTION ends the
/// chunk and whatever was typed behind it is dropped: those bytes were
/// typed at the OLD session, and forwarding them puts them in the wrong
/// shell while returning them races a detach already on its way.
pub fn feed(self: *PrefixFilter, buf: []u8) Out {
var kept: usize = 0;
// The picker and its editor are MODES, so `s` and `a` do not end the
// read: the bytes behind them were typed AT the mode that keystroke
// opened. Remembered, and reported only if nothing later said more.
var opened = false;
var editing = false;
for (buf, 0..) |b, i| {
// FIRST, and above the picker's own layer: ssh is blocked on
// this answer, and a byte that reached a session while a
// password box was up would be typed into a shell in the clear.
if (self.asking) {
switch (b) {
'\r', '\n' => {
self.asking = false;
return .{ .forward = buf[0..kept], .action = .{ .ask_answer = self.ask_line[0..self.ask_len] } };
},
// The Esc that heads an arrow key takes its tail with it:
// the chunk ends here, so `[A` never reaches a shell.
0x1b, 0x03 => {
self.asking = false;
return .{ .forward = buf[0..kept], .action = .ask_decline };
},
0x7f, 0x08 => self.ask_len -|= 1,
// High bytes too, unlike the spelling editor: a password is
// bytes and ssh takes any of them. The star count is per
// byte — feedback, not an inventory.
0x20...0x7e, 0x80...0xff => if (self.ask_len < askpass.answer_max) {
self.ask_line[self.ask_len] = b;
self.ask_len += 1;
},
else => {},
}
continue;
}
if (self.prompting) {
switch (b) {
// Submit and cancel both end the read: an Esc heading an
// arrow key must take its tail rather than hand `[A` to the
// shell, and Ctrl-C is the reflex cancel.
'\r', '\n' => {
self.prompting = false;
if (self.line_len == 0) return .{ .forward = buf[0..kept], .action = .none };
return .{ .forward = buf[0..kept], .action = .{ .add_tile = self.line[0..self.line_len] } };
},
0x1b, 0x03 => {
self.prompting = false;
return .{ .forward = buf[0..kept], .action = .none };
},
0x7f, 0x08 => self.line_len -|= 1,
0x20...0x7e => if (self.line_len < prompt_max) {
self.line[self.line_len] = b;
self.line_len += 1;
},
else => {},
}
continue;
}
if (self.picking) {
switch (b) {
0x1b => {
// A terminal writes an arrow key as three bytes of one
// read, so the tail decides which key this Esc was.
// Nothing is held across reads: a bare Escape closes now.
const tail = buf[i + 1 ..];
if (arrowMove(tail)) |d|
return .{ .forward = buf[0..kept], .action = .{ .pick_move = d } };
// A CSI or SS3 head the popup has no key for is not an
// Escape: mouse modes stay armed under the box, so a
// wheel notch would shut a popup being read. The tail
// leaves too — its digits read as row selections.
if (tail.len > 0 and (tail[0] == '[' or tail[0] == 'O'))
return .{ .forward = buf[0..kept], .action = .none };
// One level at a time: the session list was reached
// through the host list, and an Esc that closed the
// whole popup would cost a reopen to change machine.
if (self.pick_level == .sessions) {
self.pick_level = .hosts;
return .{ .forward = buf[0..kept], .action = .pick_back };
}
self.picking = false;
return .{ .forward = buf[0..kept], .action = .pick_close };
},
'j' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = 1 } },
'k' => return .{ .forward = buf[0..kept], .action = .{ .pick_move = -1 } },
'1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .pick_select = @intCast(b - '0') } },
'\r', '\n' => {
// Enter descends, then chooses. The level is flipped
// BEFORE the caller sees the action, so `.sessions`
// means "the list just opened" and `.hosts` means "a
// session was picked and the popup is closing".
if (self.pick_level == .hosts) {
self.pick_level = .sessions;
return .{ .forward = buf[0..kept], .action = .pick_enter };
}
self.picking = false;
self.pick_level = .hosts;
return .{ .forward = buf[0..kept], .action = .pick_enter };
},
// A new session on the selected host, from either level:
// the host is what a birth needs, and the session list
// is exactly where a user decides none of them will do.
'c' => {
self.picking = false;
self.pick_level = .hosts;
return .{ .forward = buf[0..kept], .action = .pick_birth };
},
'x' => {
// An end leaves the popup up: the daemon's first
// answer is a count to read, and the second press
// that forces has to land on the same row.
if (self.pick_level == .sessions)
return .{ .forward = buf[0..kept], .action = .pick_end };
// Closed, like a birth: a forget takes the host's tiles
// off the wall, and the user is owed the wall that made.
self.picking = false;
return .{ .forward = buf[0..kept], .action = .pick_forget };
},
'a' => {
self.prompting = true;
self.line_len = 0;
editing = true;
continue;
},
's', 0x03 => {
self.picking = false;
self.pick_level = .hosts;
return .{ .forward = buf[0..kept], .action = .pick_close };
},
else => {},
}
continue;
}
if (self.resizing) {
switch (b) {
'h' => return .{ .forward = buf[0..kept], .action = .{ .resize = .left } },
'j' => return .{ .forward = buf[0..kept], .action = .{ .resize = .down } },
'k' => return .{ .forward = buf[0..kept], .action = .{ .resize = .up } },
'l' => return .{ .forward = buf[0..kept], .action = .{ .resize = .right } },
0x1b => {
self.resizing = false;
continue;
},
else => {
self.resizing = false;
// Fall through to normal processing of this byte.
},
}
}
if (self.pending) {
self.pending = false;
switch (b) {
'd', detach_key => return .{ .forward = buf[0..kept], .action = .detach },
'c' => return .{ .forward = buf[0..kept], .action = .new_session },
'n' => return .{ .forward = buf[0..kept], .action = .next_session },
'p' => return .{ .forward = buf[0..kept], .action = .prev_session },
'h' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .left } },
'j' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .down } },
'k' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .up } },
'l' => return .{ .forward = buf[0..kept], .action = .{ .focus_dir = .right } },
'|', '\\' => return .{ .forward = buf[0..kept], .action = .split_right },
'-' => return .{ .forward = buf[0..kept], .action = .split_below },
'f' => return .{ .forward = buf[0..kept], .action = .fullscreen },
'r' => {
self.resizing = true;
continue;
},
's' => {
self.picking = true;
opened = true;
continue;
},
'w' => return .{ .forward = buf[0..kept], .action = .wall },
'1'...'9' => return .{ .forward = buf[0..kept], .action = .{ .focus = @intCast(b - '0') } },
'x' => return .{ .forward = buf[0..kept], .action = .remove_pane },
'X' => return .{ .forward = buf[0..kept], .action = .end_focused },
else => {},
}
continue;
}
if (b == detach_key) {
self.pending = true;
continue;
}
buf[kept] = b;
kept += 1;
}
if (editing) return .{ .forward = buf[0..kept], .action = .pick_add_open };
return .{ .forward = buf[0..kept], .action = if (opened) .pick_open else .none };
}
};
/// The most one read of the session's stdin can hand `Core.forward`. The
/// driver owns the read (the wall's is `wallview.mailbox_max`); this is
/// what the mouse filter's scratch is sized from, so a chunk that outgrew
/// it would be one the filter could not hold a candidate across.
const stdin_chunk = 16 * 1024;
/// How many rows one wheel notch moves the scrollback view. Three is what
/// every terminal's own scrollback does per notch, so it is what a user's
/// hand already expects; a page per notch (the granularity the scroll KEYS
/// use) overshoots so far that finding a line means hunting for it.
const wheel_rows: u32 = 3;
/// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones
/// into scrollback movement.
///
/// TWO ROLES, not alternatives: every tile's `Core` owns one for a focused
/// session's bytes, bypassed entirely while an application in that session
/// has asked for the mouse; and the wall owns one to hit-test a press for
/// focus, passing the report bytes on so the drag's Core still sees them.
/// Only the SGR form is recognised — the only form the client asks for.
///
/// It never holds a bare `ESC` across a read boundary: that is the complete
/// parse, and it would leave a bare Escape typed in vim sitting here until
/// the next keystroke. The hold starts at `ESC [ <`, three bytes no keyboard
/// produces, so only a split inside those three leaks through as input.
pub const MouseFilter = struct {
/// Longest report worth holding: `ESC [ <` plus three parameters. Public
/// because a caller must size `feed`'s buffer at `in.len + max_held`.
pub const max_held = 24;
/// One report the filter understood, in this client's coordinates.
/// `button` is the SGR word verbatim: what a click MEANS differs between
/// the wall's keyboard and a focused tile, so decoding is the driver's.
pub const Event = struct {
/// `wheel` is a report a driver re-emits and never selects on: the
/// notch it means is already counted in `Out.wheel`, and a
/// selection that scrolled as it grew would be one nobody made.
pub const Kind = enum { press, motion, release, wheel };
kind: Kind,
button: u16,
/// Zero-based, converted from the wire's one-based columns and rows
/// exactly here: every coordinate this client owns downstream
/// (grid rows, `Stripe.top`) counts from zero, and one conversion
/// at the edge beats one at every use.
col: u16,
row: u16,
/// How many bytes of `Out.forward` had been emitted when this report
/// completed — its position among the KEYS, not its index among the
/// events. `feed` returns two flat lists, so without this the
/// interleaving is lost: one read holding `\r` and a click focuses the
/// tile the click selected rather than the one the user chose.
at: usize,
};
/// The most reports one `feed` can produce: the shortest complete SGR
/// report is NINE bytes, and a candidate held from the previous read can
/// complete at most one more. Sized rather than coalesced so the filter is
/// total — a drag that outran a cap would lose its release. The nine is
/// counted: ten leaves the array 181 short of a full chunk.
const max_events = stdin_chunk / 9 + 1;
pub const Out = struct {
/// The bytes that were not mouse reports, in order.
forward: []const u8,
/// Net wheel notches: positive is up, into history.
wheel: i32,
/// The reports this chunk carried, in order. Borrows the filter's
/// own storage and is valid only until the next `feed`.
events: []const Event,
};
held: [max_held]u8 = undefined,
len: usize = 0,
events: [max_events]Event = undefined,
pub fn reset(self: *MouseFilter) void {
self.len = 0;
}
/// Filter one raw stdin chunk into `out`, which must hold
/// `in.len + max_held`: a candidate held from the previous read comes back
/// ahead of this chunk when it turns out not to have been a report. Both
/// size preconditions are ASSERTED, not merely stated here.
pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out {
std.debug.assert(in.len <= stdin_chunk);
std.debug.assert(out.len >= in.len + max_held);
var kept: usize = 0;
var wheel: i32 = 0;
var evs: usize = 0;
for (in) |b| {
if (self.len > 0) {
if (b == 'M' or b == 'm') {
self.held[self.len] = b;
const seq = self.held[0 .. self.len + 1];
wheel += wheelNotches(seq);
if (decodeEvent(seq)) |ev| {
self.events[evs] = ev;
// `kept` is already past the rewind that started this
// candidate, so this is a position in the FINAL forward
// and not a provisional one that later shrinks.
self.events[evs].at = kept;
evs += 1;
}
self.len = 0;
continue;
}
if ((std.ascii.isDigit(b) or b == ';') and self.len + 1 < max_held) {
self.held[self.len] = b;
self.len += 1;
continue;
}
// Not a report: give the held bytes back in the order they
// were typed, then let `b` take its chances below — it may
// be the ESC of the next candidate.
@memcpy(out[kept..][0..self.len], self.held[0..self.len]);
kept += self.len;
self.len = 0;
}
out[kept] = b;
kept += 1;
if (kept >= 3 and std.mem.eql(u8, out[kept - 3 .. kept], "\x1b[<")) {
kept -= 3;
@memcpy(self.held[0..3], "\x1b[<");
self.len = 3;
}
}
return .{ .forward = out[0..kept], .wheel = wheel, .events = self.events[0..evs] };
}
/// The event one complete SGR report means, or null for one this
/// terminal malformed. A wheel report comes back as `.wheel`, its notch
/// already spent by `wheelNotches`: a driver that scrolls on the notch
/// must not also select on the event, and a driver that hands the
/// mouse to an application must still pass the wheel along.
fn decodeEvent(seq: []const u8) ?Event {
var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';');
const button = std.fmt.parseInt(u16, it.first(), 10) catch return null;
const col = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
const row = std.fmt.parseInt(u16, it.next() orelse return null, 10) catch return null;
// One-based on the wire. A zero is a terminal talking nonsense, and
// decrementing it would wrap to 65535 — a coordinate that would
// pass every later bound check by being enormous.
if (col == 0 or row == 0) return null;
return .{
// A release says so with its final byte whatever the button
// bits claim; only a press can also be a drag (bit 5).
.kind = if (seq[seq.len - 1] == 'm')
.release
else if (button & 0x40 != 0)
.wheel
else if (button & 0x20 != 0)
.motion
else
.press,
.button = button,
.col = col - 1,
.row = row - 1,
// Filled by `feed`, which is the only thing that knows how much
// of `forward` exists yet.
.at = 0,
};
}
/// The wheel movement one complete SGR report means, or 0 for anything
/// else. Discarding those is the point: with no application asking for the
/// mouse, forwarding one types `[<0;40;12M` into the user's shell.
fn wheelNotches(seq: []const u8) i32 {
// Wheel events are presses; a release cannot be one.
if (seq[seq.len - 1] != 'M') return 0;
var it = std.mem.splitScalar(u8, seq[3 .. seq.len - 1], ';');
const button = std.fmt.parseInt(u16, it.first(), 10) catch return 0;
// Bit 6 marks the wheel buttons, bit 5 motion (a drag with the wheel
// held is not a scroll); the low two bits pick vertical from
// horizontal. Modifiers are ignored, so Ctrl+wheel scrolls.
if (button & 0x40 == 0 or button & 0x20 != 0) return 0;
return switch (button & 0x03) {
0 => 1,
1 => -1,
else => 0,
};
}
};
var winch_flag = std.atomic.Value(bool).init(false);
fn onWinch(_: c_int) callconv(.c) void {
winch_flag.store(true, .release);
}
/// Arm SIGWINCH, so `winchRaised` has something to answer.
///
/// The keyboard thread polls the flag and relayouts; there is no single
/// pump that owns the screen, so no pump consumes the signal.
pub fn watchWinch() void {
var sa: std.posix.Sigaction = .{
.handler = .{ .handler = onWinch },
.mask = std.posix.sigemptyset(),
.flags = 0,
};
std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
}
/// Whether SIGWINCH fired since the last check. Swaps to false.
pub fn winchRaised() bool {
return winch_flag.swap(false, .acq_rel);
}
/// This terminal's size, or null when there is no terminal to measure.
/// Public because the wall cuts its stripes before it has a Core, and a
/// second copy would drift on exactly the 0x0 case below.
pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
if (!std.posix.isatty(fd)) return null;
const ws = client_os.winSize(fd) orelse return null;
// A pty can report 0x0 and a zero-sized grid is invalid for the engine,
// so that is "unknown". The floor is the daemon's own, read rather than
// respelled: a size it refuses to move is as unusable as no size at all.
if (ws.col < proto.min_session_cols or ws.row < proto.min_session_rows) return null;
return .{ .cols = ws.col, .rows = ws.row };
}
// ---- side channels -----------------------------------------------------
// Everything below turns a typed semantic value into bytes for the host
// terminal. Untrusted wire validation belongs to client_core; these adapters
// only perform the operation that shared core selected.
/// Everything a driver does TO the host terminal to own a SCREEN, in order:
/// push the title, enter the alternate screen, hide the cursor, disable
/// autowrap. One half of a pair — `terminal_teardown` undoes each, and one
/// test pins them together so neither drifts alone. Written once for a
/// driver's LIFETIME, not per session: the wall holds the screen while focus
/// moves and a tile writes only `session_claim`. Autowrap goes off so an
/// oversized row clips at the right edge instead of shifting the paint down.
const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
/// The half about holding a SESSION on somebody's terminal: the mouse modes
/// its wheel is read out of, and nothing else. A tile's focus claim writes
/// this, then the session's own modes on top. One shared constant, so a mode
/// added here is added to every claim and one dropped from `session_release`
/// is dropped from every path that undoes one.
///
/// The enables are the CLIENT's own: with no mouse reporting on, a host
/// terminal answers the wheel by synthesising arrow keys (DEC 1007), which
/// move the shell's history instead of the view.
const session_claim = client_mouse_setup;
/// The wall's own capture, exported for the KEYBOARD to re-arm right after
/// it writes `session_release` on a focus move. The release lowers every
/// mouse mode — the wall's three included — and the re-arm used to be the
/// incoming tile's claim, a pump-side write. A pump parked in `dial`'s
/// backoff never runs its claim, so focusing an unreachable tile left the
/// terminal deaf to every later click, including the one that would have
/// moved focus back off it. The wall hears clicks for as long as it owns
/// the screen, no pump required; a claim that follows re-asserts the same
/// modes, which is idempotent.
pub const wall_mouse_capture = client_mouse_setup;
/// The mouse modes the client asks its own terminal for when no application
/// in the session wants them: presses (1000) and motion with a button down
/// (1002), in SGR (1006).
///
/// 1002 is held for the whole run because there is no drag-length to arm it
/// for: motion reporting has to be on BEFORE the press whose drag it reports.
/// The cost is a report per motion event; nvim with `set mouse=a` pays the
/// same. 1003 stays refused — motion with NO button down answers no question
/// this client asks.
///
/// The LIST is the fact, not the escape: `appendMouseModes` level-sets the
/// same modes, so one spelled here and not there is turned on then off.
const client_mouse_capture = [_]u16{ 1000, 1002, 1006 };
const client_mouse_setup = blk: {
var s: []const u8 = "";
for (client_mouse_capture) |dec| s = s ++ std.fmt.comptimePrint("\x1b[?{d}h", .{dec});
break :blk s;
};
/// Whether `dec` is one of the modes the client asks for on its own behalf.
fn inClientCapture(comptime dec: u16) bool {
for (client_mouse_capture) |c| {
if (c == dec) return true;
}
return false;
}
/// Everything the client must undo on its way out, in one literal: leaving
/// any of these set hands the user a terminal that behaves oddly long after
/// mux exited. `?2004l` leads because a SESSION asked for it.
///
/// That `?2004l` restores 2004 to the power-on default rather than to what
/// the outer program had, because mux never asked. Correct by observation:
/// zsh and readline both DISARM bracketed paste before running each command,
/// so host 2004 is already off for the whole time mux runs.
///
/// The title pop (`23;0t`) is the one entry restoring the user's OWN value,
/// off the terminal's stack, because mux cannot read a title back. It sits
/// SECOND TO LAST: `?1049l` must remain the final bytes a tty client writes,
/// which the e2e's pty capture asserts by dropping exactly that tail.
const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l";
/// Undoes `session_claim` and everything a SESSION asked this terminal for
/// while it held it: bracketed paste, and the whole mouse table, since a
/// focused tile's application can have asked for modes the client never did.
///
/// Every route out of a terminal claim writes these bytes, and one test pins
/// all four. Public because the focus move writes it from another thread: the
/// release must be ORDERED against the next tile's claim, not merely happen.
pub const session_release = "\x1b[?2004l" ++ mouse_teardown;
/// What a driver that lends its screen to one session at a time writes on the
/// way in and out. The wall is the driver: it holds the alternate screen for
/// its whole life, so the screen half is written once and never by a tile.
///
/// The teardown IS `terminal_teardown`, by design: `Ctrl-\ d` ends the run
/// from any state, so every mode a session set through a tile has to come off
/// here as well as at the focus handover.
///
/// The TITLE is deliberately not symmetric. A focus move leaves the session's
/// title standing; the wall pushed one for its whole life and pops it here.
/// Restoring per focus move would need a title to restore TO, and mux cannot
/// read one back. That push is exactly one deep — an unmatched POP restores
/// somebody else's title — and this write and that pop are the only two.
pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
pub const wall_teardown = terminal_teardown;
/// Built from the wire table, so a mode added there cannot be missed here
/// and leave the user's shell reporting clicks after mux exits.
const mouse_teardown = blk: {
var s: []const u8 = "";
for (proto.mouse_modes) |m| s = s ++ std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec});
break :blk s;
};
/// Render validated terminal state as the DECSET/DECRST writes it implies.
fn appendTermState(
out: *std.ArrayList(u8),
alloc: std.mem.Allocator,
state: client_core.State,
) !void {
switch (state) {
.terminal_modes => |modes| {
try out.appendSlice(
alloc,
if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l",
);
try appendMouseModes(out, alloc, modes);
},
}
}
/// Who owns the wheel. A level-set, not a diff: the terminal may
/// be one mux never set up.
fn appendMouseModes(
out: *std.ArrayList(u8),
alloc: std.mem.Allocator,
modes: proto.TermModes,
) !void {
const app = modes.appMouse();
inline for (proto.mouse_modes) |m| {
// The client's own capture set comes from the one list that
// `client_mouse_setup` is built from, so the startup write and this
// level-set cannot disagree about what "ours" is.
const on = if (app) @field(modes, m.field) else comptime inClientCapture(m.dec);
try out.appendSlice(alloc, if (on)
comptime std.fmt.comptimePrint("\x1b[?{d}h", .{m.dec})
else
comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}));
}
}
/// Re-validated, not trusted: `wasm_core.zig` default-initialises
/// an invalid `ClipboardSet`.
fn appendHostEffect(
out: *std.ArrayList(u8),
alloc: std.mem.Allocator,
effect: client_core.Effect,
) !void {
switch (effect) {
.clipboard_set => |clip| {
if (!client_core.validClipboard(clip.target, clip.base64)) return;
try out.appendSlice(alloc, "\x1b]52;");
try out.append(alloc, clip.target);
try out.append(alloc, ';');
try out.appendSlice(alloc, clip.base64);
try out.append(alloc, 0x07);
},
.bell => try out.append(alloc, 0x07),
}
}
/// `owns_terminal` is true by design: the claim gates a session's
/// event, not this drag.
pub fn writeSelectionCopy(
alloc: std.mem.Allocator,
out_fd: std.posix.fd_t,
text: []const u8,
) !void {
const b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(text.len));
defer alloc.free(b64);
_ = std.base64.standard.Encoder.encode(b64, text);
try writeSideChannel(alloc, out_fd, true, client_core.Effect, .{ .clipboard_set = .{
.target = 'c',
.base64 = b64,
} }, appendHostEffect);
}
/// An empty title CLEARS the host terminal's; the peer need not
/// be this daemon version.
fn appendTermTitle(
out: *std.ArrayList(u8),
alloc: std.mem.Allocator,
payload: []const u8,
) !void {
if (payload.len == 0 or payload.len > proto.term_title_max) return;
for (payload) |b| if (b < 0x20 or b == 0x7f) return;
try out.appendSlice(alloc, "\x1b]0;");
try out.appendSlice(alloc, payload);
try out.append(alloc, 0x07);
}
/// `owns_terminal` gates every channel: the claim arms the teardown, so a
/// mode set under `.none` is one nothing undoes. `append` is declared, not
/// `anytype`: allocation is a builder's only failure, so empty means refusal.
fn writeSideChannel(
alloc: std.mem.Allocator,
stdout_fd: std.posix.fd_t,
owns_terminal: bool,
comptime Value: type,
value: Value,
comptime append: fn (*std.ArrayList(u8), std.mem.Allocator, Value) std.mem.Allocator.Error!void,
) !void {
if (!owns_terminal) return;
var esc: std.ArrayList(u8) = .empty;
defer esc.deinit(alloc);
try append(&esc, alloc, value);
if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
}
// ---- prediction --------------------------------------------------------
// Nothing below writes to the replica (see the invariants at the top), which
// is why the replica stays comparable to `mux d dump` at every instant.
/// What the replica shows at one cell — the `prev_ch` a prediction is judged
/// against later. Read at the PREDICTED cursor, not the replica's own:
/// mid-burst the wrong one turns "not answered yet" into "contradicted".
fn replicaCellChar(alloc: std.mem.Allocator, replica: *const Grid, at: predict.CursorPos) u8 {
const plain = replica.dumpPlain(alloc) catch return ' ';
defer alloc.free(plain);
const text: predict.PlainGrid = .{ .text = plain, .cols = replica.cols };
return text.cellChar(at.y, at.x) orelse ' ';
}
/// Judge the overlay against the replica as it stands — after the
/// replica has taken the frame, never before.
fn reconcileOverlay(
alloc: std.mem.Allocator,
overlay: *predict.Overlay,
replica: *const Grid,
seq: u64,
now_ms: i64,
) predict.Verdict {
const plain = replica.dumpPlain(alloc) catch return .none;
defer alloc.free(plain);
return overlay.reconcile(
predict.PlainGrid{ .text = plain, .cols = replica.cols },
seq,
now_ms,
);
}
/// Idempotent, and called after every authoritative paint: a delta
/// repaints whole rows and would wipe an outstanding prediction.
fn paintOverlay(
alloc: std.mem.Allocator,
overlay: *predict.Overlay,
base: grid_mod.CursorPos,
vp: paint_mod.Viewport,
out_fd: std.posix.fd_t,
) void {
if (!overlay.visible() or overlay.pendingCount() == 0) return;
var paint: std.ArrayList(u8) = .empty;
defer paint.deinit(alloc);
paint.appendSlice(alloc, paint_mod.sync_begin) catch return;
var i: usize = 0;
while (i < overlay.pendingCount()) : (i += 1) {
const cell = overlay.pendingAt(i).cell;
if (cell.row >= vp.rows or cell.col >= vp.cols) continue;
var b: [32]u8 = undefined;
// Underlined, so a prediction is visibly a prediction until the
// daemon's own row content replaces it.
const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{
cell.row + vp.top + 1,
cell.col + vp.left + 1,
cell.ch,
}) catch continue;
paint.appendSlice(alloc, s) catch return;
// Counted here rather than at prediction time, because this is
// where a cell actually reaches the screen — including one queued
// while unconfident that a promotion has since made visible. The
// overlay counts it once however often this redraws it.
overlay.markPainted(i);
}
const pc = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
const cur = paint_mod.clampCursor(.{ .x = pc.x, .y = pc.y }, vp);
var cbuf: [32]u8 = undefined;
const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{
cur.y + vp.top + 1,
cur.x + vp.left + 1,
}) catch return;
paint.appendSlice(alloc, tail) catch return;
proto.writeAllFd(out_fd, paint.items) catch {};
}
/// The chunk goes to the daemon unchanged whatever happens here:
/// prediction alters what the screen shows, never what the shell reads.
fn offerKeystroke(
alloc: std.mem.Allocator,
overlay: *predict.Overlay,
replica: *const Grid,
chunk: []const u8,
vp: paint_mod.Viewport,
out_fd: std.posix.fd_t,
) void {
if (chunk.len != 1) {
// An escape, a multi-byte character or a paste: none is one cell's
// worth of change. Counted HERE, because a paste's lead byte is
// printable and `predictAt` would speculate on it.
overlay.recordSuppressed();
return;
}
const base = replica.cursor;
const at = overlay.predictedCursor(.{ .x = base.x, .y = base.y });
const out = overlay.predictAt(.{
.cursor = at,
.ch = chunk[0],
.prev_ch = replicaCellChar(alloc, replica, at),
.now_ms = std.time.milliTimestamp(),
});
switch (out) {
.display => paintOverlay(alloc, overlay, base, vp, out_fd),
// Queued but unearned, or refused outright: either way nothing is
// drawn, which is the entire safety property.
.hidden, .suppressed => {},
}
}
/// The one machine-readable line `MUX_PREDICT_STATS=1` produces. A pure
/// function so the format the e2e greps for is pinned by a test rather than
/// by whatever the process happened to print.
fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 {
return std.fmt.bufPrint(
buf,
"predict made={d} displayed={d} confirmed={d} contradicted={d}" ++
" expired={d} abandoned={d} suppressed={d} local={d}",
.{
c.made, c.displayed, c.confirmed, c.contradicted,
c.expired, c.abandoned, c.suppressed, c.local,
},
);
}
const predict_stats_len = 192;
/// The prediction counters, re-exported. A driver that has to carry them
/// across a thread boundary (wallview's wall, whose tile Cores never reach
/// a `deinit` that could print them) names the type through the module it
/// already talks to, rather than taking a module edge for one struct.
pub const PredictCounters = predict.Counters;
/// The `MUX_PREDICT_STATS` line, on the way out. A tile's Core lives on a
/// detached pump the process exit kills where it stands, so the driver that
/// owns the exit prints it — on the normal screen, not a discarded one.
pub fn dumpPredictStats(c: predict.Counters) void {
const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return;
if (!std.mem.eql(u8, want, "1")) return;
var buf: [predict_stats_len]u8 = undefined;
const line = formatPredictStats(&buf, c) catch return;
std.debug.print("{s}\n", .{line});
}
/// Turn wheel notches into the arrow keys an alt-screen application reads,
/// `wheel_rows` per notch. Sent as input, never predicted — a guess at a
/// full-screen application's cursor is about a layout mux cannot see — and
/// batched, since one frame per arrow would put a hundred on the wire.
fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void {
const seq = altScrollSeq(wheel, app_cursor);
var buf: [alt_scroll_batch * 3]u8 = undefined;
var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows;
while (left > 0) {
const n = @min(left, alt_scroll_batch);
for (0..n) |i| @memcpy(buf[i * 3 ..][0..3], seq);
try transport.writeFrame(.input, buf[0 .. n * 3]);
left -= n;
}
}
/// Arrows per alternate-scroll frame. Twenty-one notches' worth, which no
/// hand produces in one read; the batching exists to bound the buffer, not
/// to pace anything.
const alt_scroll_batch: u32 = 64;
/// DECCKM decides what an arrow key IS, and getting it wrong is silent:
/// `less` reads `ESC O A` and ignores `ESC [ A`. Three bytes either
/// way, as `sendAltScroll` assumes.
fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 {
if (app_cursor) return if (wheel > 0) "\x1bOA" else "\x1bOB";
return if (wheel > 0) "\x1b[A" else "\x1b[B";
}
fn requestScrollPage(
transport: anytype,
rep: *const Replica,
rows_up: u32,
size: proto.Size,
) !void {
// The view is `size.rows` rows starting `rows_up` above the live
// viewport top; the row math lives with the replica's history_rows
// (replica.zig).
const start = rep.scrollStart(rows_up);
try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows));
}
// ---- the interaction core ----------------------------------------------
/// What a step that touched the transport found there. Reported rather than
/// thrown: a dead link is an ordinary event a driver answers by rebuilding
/// it, not an error that should unwind past the replica — the replica is
/// the whole reason a dropped link can be a non-event.
pub const Step = enum { ok, lost };
/// Whether `frame`'s per-type handler changed anything. Internal to the
/// routing: `frame` is what turns it into a `Routed` the driver reads.
const Pass = enum { carry_on, skip };
/// What a `selection_reply` turned out to be worth — `Core.selectionCopy`'s
/// answer, and the whole of what a driver has to decide about.
pub const Copy = union(enum) {
/// Nothing to do, and nothing to say: somebody else's reply, one whose
/// highlight is gone, an empty selection, or a refusal the user cannot act
/// on. None is a sentence worth putting over their work.
none,
/// The selected text, borrowing the frame payload and valid only while
/// it is.
text: []const u8,
/// `.ok`, and past what OSC 52 can carry. Said out loud, because
/// `appendHostEffect` refuses by writing NOTHING and a silent stop looks
/// exactly like a copy that worked. Never truncated: the user would find
/// out when they paste it.
too_large,
};
/// What one frame turned out to be, once the Core has done its half. The
/// exhaustive switch over `proto.MsgType` lives in `Core.frame` and nowhere
/// else; this is the narrow set a driver still has to act on.
const Routed = enum {
/// Done here; the driver's pass carries on.
handled,
/// Done here, and it changed nothing — the driver's `continue`.
skip,
/// The replica took state from it: a snapshot, or a delta it accepted.
/// Named apart from `handled` because the driver hangs its own
/// bookkeeping off the first state of an attach — the tile narrates
/// `[up]` on its label bar.
state,
/// The replica took state and then refused what arrived: it can no longer
/// be trusted. The driver re-attaches quoting no seq and no epoch, since
/// what it holds is exactly what is untrusted.
resync,
/// Not the Core's: what a lifecycle frame or a session list MEANS is
/// entirely the driver's — an exit ends a client and merely relabels a
/// tile — so the Core names them and stops. Agent frames join them from
/// the other end, since the driver holds their local socket.
///
/// `selection_reply` is the one the driver must call BACK about: the
/// terminal it copies to is the wall's, and the tile that asked is
/// usually not the focused one.
not_mine,
};
/// Where a Core's paints are allowed to land. A tile's terminal is shared
/// with N stripes on N threads, so every paint asks first and the answer is
/// held until it finishes: a focus move mid-paint would put one session's
/// rows on another's screen. Side channels gate on `Core.claim` instead.
pub const Sink = struct {
ctx: ?*anyopaque = null,
/// True when the Core may paint, with whatever lock makes that true
/// now held. Null means "always, holding nothing".
begin: ?*const fn (?*anyopaque) bool = null,
/// Releases what `begin` took. Called only after a `begin` said true.
end: ?*const fn (?*anyopaque) void = null,
};
/// Whether a Core holds a claim on its terminal, and so whether there is a
/// teardown to write. A Core never owns a SCREEN — the driver lends one to a
/// session at a time — so a claim is only what a SESSION brings.
///
/// It ARMS the undo, which is the load-bearing part: nothing may write a mode
/// to a terminal this is `.none` on, because `.none` is the state in which
/// nothing is arranged to unset it. A session that arms bracketed paste and
/// then dies never sends the frame that would clear it, so this teardown is
/// the only `?2004l` mux is certain to write.
pub const Claim = enum { none, session };
/// One session's interaction state and everything done to a terminal on its
/// behalf: the replica frames are replayed into, the prediction overlay over
/// it, the chord and mouse filters, and the terminal it happens on.
///
/// The driver owns the transport and the loop; the Core owns each event:
///
/// * `initSized` / `deinit` — the Core owns its Grid, its overlay and
/// whatever terminal claim it still holds, and puts all three back.
/// * `claimTerminal` / `releaseTerminal` — the terminal a tile BORROWS
/// while focused. Raw mode and SIGWINCH belong to the driver.
/// * per pass `idle`; per frame `frame(type, payload)`, which routes and
/// returns only what is LEFT, the replica's apply included.
/// * per read of stdin: the DRIVER's. It feeds its own `PrefixFilter` and
/// calls `forward` with the bytes that were not a chord.
/// * around a reconnect: `dropScrollView` before, `reattached` after.
///
/// It depends on a Grid, a Replica, the overlay, the painter and the
/// shared decoder — never a transport type. Nothing here is a singleton: a
/// tile brings its own Core, so there is never a second applier for one tile.
pub const Core = struct {
/// The tile index a `Core` selects under: it is the only session on its
/// rect, so there is exactly one. Named because three sites must agree —
/// `hitTest` stamps it, `highlight` passes it, `paintDragChange` reads it.
const focus_tile: usize = 0;
alloc: std.mem.Allocator,
/// The user's terminal. `in_fd` is the tty this session is typed at,
/// and the Core asks it exactly one question — whether it IS one —
/// because the keyboard belongs to the driver. `out_fd` is everything
/// this paints on.
in_fd: std.posix.fd_t,
out_fd: std.posix.fd_t,
/// Whether there is a terminal to claim at all. A session whose stdin
/// is a pipe asks nobody for mouse reports and claims nothing — see
/// `writeSideChannel` for what that costs.
is_tty: bool,
/// The local clip size. The replica follows the daemon's authoritative
/// grid, which under latest-wins may differ from this; paints are
/// clipped to this.
size: proto.Size,
/// The terminal row this tile's rect starts at. A wall tile paints
/// inside its own rect, not the screen's origin; the plain client and
/// `mux a` leave it 0 and paint from row 1 as before.
row_off: u16 = 0,
/// The terminal column this tile's rect starts at. A beside-neighbour
/// would be blanked by a whole-line clear, so every painter addresses
/// its own column origin. The plain client and `mux a` leave it 0.
col_off: u16 = 0,
/// Whether this Core may clear the whole screen. A piped wall owns
/// every row; a tile on a tty sits under its label bar and does not.
/// Set by the driver that knows the layout, not inferred from `row_off`.
owns_screen: bool = true,
/// The replay core (replica.zig). Public because the driver reads it: a
/// reconnect quotes `last_seq`/`session_epoch`, and `state_since_attach`
/// tells a refusal from a shell exiting. The Core owns the Grid.
rep: Replica,
/// Speculative echo. Born `.never` and stays there until a daemon tells
/// it otherwise, so an old daemon that has never heard of pty_mode gets
/// a client that predicts nothing at all.
overlay: predict.Overlay,
/// Semantic terminal state and host effects are decoded once here, then
/// handed to the native adapters above. The web client owns another
/// instance of this same platform-neutral state machine.
semantic: client_core.ClientCore = .{},
/// Mouse reports arrive in the same reads as keystrokes; this splits
/// them back out, across read boundaries.
mouse: MouseFilter = .{},
/// The mux selection over THIS session, while this Core holds the
/// terminal. A wall tile that is not focused has `claim == .none` and so never
/// sees a report at all: the drag belongs to the focused tile, on its
/// own hit-test.
drag: select.Drag = .{},
/// The last id this Core put on the wire. Per-Core and not global: one
/// link carries one conversation, and `client_core` correlates against
/// the pending one it was handed.
sel_id: u32 = 0,
/// `history_rows` as of the request in flight, and the whole staleness
/// test: an eviction between ask and answer renames the coordinate space
/// and yields text that is `.ok` and not what was highlighted. Only a
/// LOWER count is evidence — output raises it without moving row zero.
sel_watermark: u32 = 0,
/// The selection the request was taken from, compared against what is
/// still held when the answer comes back. A reply that outlived its own
/// highlight — a relayout, a forget, a focus move, a resync — is text for
/// rows nobody is looking at any more.
sel_range: ?select.Range = null,
/// Scroll mode: 0 = live; N = the screenful whose bottom sits N rows above
/// live. Rows, not pages: the wheel moves a few lines and the keys move a
/// screen. View state, not replay state, so it stays out of the Replica.
scroll_rows: u32 = 0,
/// What this Core holds on the terminal, and therefore what it may
/// write there and what it must undo. See `Claim`.
claim: Claim = .none,
/// Whether this Core may paint right now, and what to hold while it
/// does. Set by a driver that shares its terminal; see `Sink`.
sink: Sink = .{},
/// Whether `deinit` is the right place to print the prediction stats.
/// False for a tile's Core, which lives on a detached pump: `deinit` is
/// not reliably reached, and when it is it prints onto a screen about to
/// be discarded and then a second time from the driver that owns the exit.
owns_stats: bool = true,
/// The first paint after a reconnect must be a full one, so the
/// [reconnecting] banner goes away with everything else now stale.
repaint_after_resync: bool = false,
/// The mouse filter's scratch — sized to hold one read of stdin plus
/// whatever a previous read left mid-report (see MouseFilter.feed).
mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
/// The read rebuilt with its reports in the pane's coordinates, for
/// the application that owns the mouse. Sized like `mouse_buf`: a
/// relocated report is never longer than the one it replaces, since
/// an origin only comes off a coordinate and a clamp only lowers it.
mouse_out: [stdin_chunk + MouseFilter.max_held]u8 = undefined,
/// Born at a size the driver measured: the grid has to be born at
/// the size the first paint clips to.
pub fn initSized(
alloc: std.mem.Allocator,
in_fd: std.posix.fd_t,
out_fd: std.posix.fd_t,
size: proto.Size,
) !Core {
// The driver's layout was cut from ONE reading of the terminal, so
// a second ioctl here would clip a tile to rows the wall
// does not believe in.
const g = try Grid.init(alloc, size.cols, size.rows);
return .{
.alloc = alloc,
.in_fd = in_fd,
.out_fd = out_fd,
.is_tty = std.posix.isatty(in_fd),
.size = size,
.rep = Replica.init(alloc, g),
.overlay = predict.Overlay.init(alloc, size.cols, size.rows),
};
}
/// The terminal claim goes back FIRST, so the stats line and whatever
/// the driver prints on its way out land on a terminal already out of
/// this session's modes.
pub fn deinit(self: *Core) void {
// Only undo what was claimed: the screen belongs to the driver, which
// is still using it. `.write` always — this is the path a pump takes
// when its SESSION ends while holding the terminal, so nobody else
// wrote the release.
self.releaseTerminal(.write);
if (self.owns_stats) dumpPredictStats(self.overlay.counters);
self.overlay.deinit();
self.rep.grid.deinit();
}
/// A pump calls this before claiming, to follow a resize.
pub fn adoptSize(self: *Core, size: proto.Size) void {
self.size = size;
}
/// Take the terminal for this session ALONE. Under the SINK for ORDER,
/// not painting: the wall writes the previous holder's release under the
/// same lock, and a claim outside it can land AFTER that release, leaving
/// modes nothing undoes. The session's own modes go on top, because a
/// claim's resize is answered by `resyncSnapshot`, which carries none.
pub fn claimTerminal(self: *Core) bool {
if (!self.is_tty or self.claim != .none) return false;
if (!self.beginPaint()) return false;
defer self.endPaint();
// Set BEFORE the writes: it is what admits them (`writeSideChannel`
// refuses everything under `.none`), and what arms taking them off.
self.claim = .session;
proto.writeAllFd(self.out_fd, session_claim) catch {};
// Written raw rather than through the wrapper `semanticFrame` uses:
// the sink is not reentrant and this call already holds it.
writeSideChannel(
self.alloc,
self.out_fd,
true,
client_core.State,
.{ .terminal_modes = self.semantic.terminal_modes },
appendTermState,
) catch {};
return true;
}
/// Who writes the undo when a claim is given up. `.already_written`
/// exists because a shared terminal's handover must be ORDERED: the wall
/// writes the release on the thread that moves the focus. An argument
/// rather than a second method, so every call site has to say which —
/// the wrong one is a terminal left reporting clicks.
const Undo = enum { write, already_written };
/// Give the terminal back: the release, and every other way out. Nothing
/// goes on the WIRE, but plenty comes off the terminal — a wall left
/// reporting clicks into the user's shell is what this pairs against.
/// The scroll view goes with it either way, or the resuming stripe comes
/// back showing nothing. Idempotent, and the teardown follows the claim.
pub fn releaseTerminal(self: *Core, undo: Undo) void {
const held = self.claim;
self.claim = .none;
self.dropScrollView();
// The highlight goes with the screen it was drawn on: kept, it would
// reappear over rows chosen in another session's lifetime, and a reply
// still in flight would copy that text.
self.drag.clear();
if (undo == .already_written) return;
switch (held) {
.none => {},
.session => proto.writeAllFd(self.out_fd, session_release) catch {},
}
}
/// The grid this Core's replica paints from.
pub fn grid(self: *Core) *Grid {
return self.rep.grid;
}
/// Returned BY VALUE and kept on the caller's stack for the length of
/// the paint: `sink()` hands the painter a pointer to it.
fn highlight(self: *Core) Highlight {
return .{ .drag = &self.drag, .tile = focus_tile, .history_rows = self.rep.history_rows };
}
/// May this Core paint now, and hold that until `endPaint`? Every
/// GRID write goes through the pair.
fn beginPaint(self: *Core) bool {
const b = self.sink.begin orelse return true;
return b(self.sink.ctx);
}
fn endPaint(self: *Core) void {
if (self.sink.end) |e| e(self.sink.ctx);
}
/// The rect this Core owns, in the shape every painter takes.
pub fn viewport(self: *const Core) paint_mod.Viewport {
return .{
.top = self.row_off,
.left = self.col_off,
.rows = self.size.rows,
.cols = self.size.cols,
};
}
/// The whole screen from the replica — a local repaint at zero round
/// trips. The overlay goes back on top, because the rows just drawn have
/// overwritten predictions still outstanding.
pub fn repaint(self: *Core) !void {
if (!self.beginPaint()) return;
defer self.endPaint();
const hl = self.highlight();
try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
}
/// The terminal cursor the painters end on — the same clamp-then-offset
/// math. An overlay may park ahead; the replica cursor is the
/// approximation.
pub fn screenCursor(self: *Core) grid_mod.CursorPos {
const vp = self.viewport();
const c = paint_mod.clampCursor(self.rep.grid.cursor, vp);
return .{ .x = c.x + vp.left, .y = c.y + vp.top };
}
/// Repaint the rows a drag report changed, and only those: the anchor
/// does not move, so only the rows between the two ends can change. Every
/// row is still ASKED rather than reasoned about — a wrong answer is a
/// highlight with a hole in it. Painting the whole screen instead cost
/// 4.8 KB a cell at 120x40. The overlay goes back on top, as in `repaint`.
fn paintDragChange(self: *Core, was: ?select.Range) !void {
// Scroll mode owns the screen: `renderScrollback` paints rows this
// Core does not hold, so a row from the live replica would land on a
// page it cannot address. Leaving scroll mode repaints in full.
if (self.scroll_rows > 0) return;
const now = self.drag.range();
if (std.meta.eql(was, now)) return;
const cols: u16 = @intCast(self.rep.grid.cols);
const grid_rows: u16 = @intCast(self.rep.grid.rows);
const limit = @min(grid_rows, self.size.rows);
var rows: std.ArrayList(u16) = .empty;
defer rows.deinit(self.alloc);
var y: u16 = 0;
while (y < limit) : (y += 1) {
const abs = self.rep.history_rows + y;
const before = if (was) |r| r.span(focus_tile, abs, cols) else null;
const after = if (now) |r| r.span(focus_tile, abs, cols) else null;
if (!std.meta.eql(before, after)) try rows.append(self.alloc, y);
}
if (rows.items.len == 0) return;
if (!self.beginPaint()) return;
defer self.endPaint();
const hl = self.highlight();
try paint_mod.renderRowsClipped(
self.alloc,
self.rep.grid,
self.viewport(),
hl.sink(),
rows.items,
self.out_fd,
);
paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
}
/// The whole screen from the replica, with nothing put back on top —
/// every rollback path, where the overlay has just been abandoned.
fn paintFull(self: *Core) !void {
if (!self.beginPaint()) return;
defer self.endPaint();
const hl = self.highlight();
try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
}
/// A one-line marker in the corner, painted over by the next full
/// repaint. ASCII only: `bannerText` places the label by byte length.
pub fn banner(self: *Core, text: []const u8) void {
if (!self.is_tty or !self.beginPaint()) return;
defer self.endPaint();
paint_mod.paintBanner(self.out_fd, self.size.cols, text, self.row_off, self.col_off);
}
/// The only thing that can retire a prediction the application
/// answered by going quiet: no frame is coming, so reconcile never
/// runs again.
pub fn idle(self: *Core) !void {
if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) {
try self.paintFull();
}
}
/// The replica has taken a snapshot: tell the overlay, rebuild under it.
/// A snapshot answers a resize and ends a reconnect; neither says a
/// prediction was WRONG, only that we can no longer find out — so the
/// queue goes and the counters do not move.
fn snapshotTaken(self: *Core) !void {
self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows);
self.overlay.setResizePending(false);
self.overlay.flush();
self.overlay.noteSeq(self.rep.last_seq);
if (self.scroll_rows == 0) {
try self.paintFull();
self.repaint_after_resync = false; // banner painted over
}
}
/// The replica has taken a delta: judge the overlay against it and
/// paint. `payload` is the same frame the replica was fed — the row
/// deltas are what `paintDeltaClipped` draws.
fn deltaTaken(self: *Core, payload: []const u8) !void {
// Judged against the replica the frame has just been fed into,
// which is the only authority there is.
const verdict = reconcileOverlay(
self.alloc,
&self.overlay,
self.rep.grid,
self.rep.last_seq,
std.time.milliTimestamp(),
);
// While scrolled the replica still tracks live output; the repaint
// on scroll exit comes from it.
if (self.scroll_rows == 0 and self.beginPaint()) {
defer self.endPaint();
if (self.repaint_after_resync or verdict == .contradicted) {
// First frame back after a reconnect: the daemon sent only
// what changed, but the screen still carries the banner. A
// contradiction takes the same route — the queue has just been
// abandoned, and a full repaint is the rollback that is
// certainly right. Painted raw, not through `paintFull`: this
// and the overlay below are ONE hold of a non-reentrant sink.
const hl = self.highlight();
try paint_mod.renderClipped(self.alloc, self.rep.grid, self.viewport(), hl.sink(), null, self.owns_screen, self.out_fd);
self.repaint_after_resync = false;
} else {
const hl = self.highlight();
try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.grid, self.viewport(), hl.sink(), self.out_fd);
}
// Last, and after either paint: the rows the daemon just sent
// have overwritten anything drawn on them, including predictions
// that are still outstanding.
paintOverlay(self.alloc, &self.overlay, self.rep.grid.cursor, self.viewport(), self.out_fd);
}
}
/// The session handed the terminal back and forth, or took it over. Mode
/// churn is ordinary — readline does it around every command — so the
/// repaint is spent only when the flush took something off the screen.
fn ptyModeChanged(self: *Core, payload: []const u8) !Pass {
const flags = proto.decodePtyMode(payload) catch return .skip;
const had_pending = self.overlay.pendingCount() > 0;
self.overlay.setMode(flags);
if (had_pending and self.overlay.pendingCount() == 0 and self.scroll_rows == 0) {
try self.paintFull();
}
return .carry_on;
}
/// A page of history, answering the request `forward` sent when the
/// view moved. Ignored once the view is live again: the page would be
/// painted over a screen it no longer describes.
fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
// 6 = the `scrollback_chunk` header (u32 start, u16 count); the
// CellRows follow it. Short of that there is nothing to render.
if (self.scroll_rows == 0 or payload.len < 6) return .skip;
const count = std.mem.readInt(u16, payload[4..6], .little);
// The daemon encoded these at ITS grid width, which is the width the
// live grid holds too: a client never asks for history at a size it
// did not attach at.
const rows = grid_mod.decodeRows(self.alloc, payload[6..], count, self.rep.grid.cols) catch return .skip;
defer grid_mod.freeRows(self.alloc, rows);
if (!self.beginPaint()) return .carry_on;
defer self.endPaint();
try paint_mod.renderScrollback(self.alloc, rows, self.rep.grid.cols, self.viewport(), self.owns_screen, self.out_fd);
return .carry_on;
}
/// A `term_event` or `term_modes` frame: decoded by the shared core, then
/// rendered by this platform's adapters. The DECODE happens whatever the
/// sink says and the WRITE does not — an unfocused tile tracks its
/// session's modes for the claim that will need them.
///
/// The claim answers ORDER, the sink answers ATOMICITY: a 64 KiB OSC 52
/// payload leaves here as a write loop, and another thread's screen clear
/// spliced into it leaves the terminal hunting for a string terminator.
fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void {
const decoded = self.semantic.receive(frame_type, payload);
if (!self.beginPaint()) return;
defer self.endPaint();
switch (decoded) {
.ignored => {},
.state => |state| {
// Mode samples are deliberately not deduplicated: reasserting
// DECSET/DECRST 2004 is a harmless level-set that restores the
// host after a new connection. Occurrences take the arm below.
try writeSideChannel(
self.alloc,
self.out_fd,
self.claim != .none,
client_core.State,
state,
appendTermState,
);
},
.effect => |effect| try writeSideChannel(
self.alloc,
self.out_fd,
self.claim != .none,
client_core.Effect,
effect,
appendHostEffect,
),
// Nothing to write: a `.reply` answers something this client
// ASKED for, and the one there is goes out through
// `selectionCopy`. Named, so a second kind of reply is an edit here.
.reply => {},
}
}
/// The session's window title. Repeats are expected — every attach
/// resends it — and harmless: setting a title to the value it already
/// holds has no counter or stack behind it, and the daemon never sends
/// an empty one, so a repeat cannot clear what the user is looking at.
fn titleFrame(self: *Core, payload: []const u8) !void {
// Under the sink, `semanticFrame`'s atomicity reason: a title is
// bounded at `term_title_max` but it is still an OSC with a
// terminator, and a clear spliced into one is the same lost screen.
if (!self.beginPaint()) return;
defer self.endPaint();
try writeSideChannel(
self.alloc,
self.out_fd,
self.claim != .none,
[]const u8,
payload,
appendTermTitle,
);
}
/// One frame from the daemon, routed. The exhaustive switch over the wire
/// lives HERE and nowhere else — including the replica's apply, because a
/// driver applying on its own is a second switch over the same enum.
///
/// What comes back is only what is LEFT (see `Routed`). Errors are the
/// replica's, unchanged, and the two snapshot failures are told apart the
/// way `Replica.apply` documents them: `error.BadPayload` left the grid
/// untouched, so it is `.skip`; `error.SnapshotAborted` did not — the grid
/// is blank and `last_seq` claims to be current — so it goes out to the
/// driver, which ends the tile rather than paint from a replica that
/// holds nothing.
pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed {
switch (frame_type) {
.snapshot => {
_ = self.rep.apply(.snapshot, payload) catch |err| switch (err) {
error.BadPayload => return .skip,
error.SnapshotAborted => return err,
else => |e| return e,
};
try self.snapshotTaken();
return .state;
},
.delta => {
// `apply` marks state BEFORE it can refuse, so a delta the
// replica could not compose still proves the attach landed
// — which is why `.resync` counts as state to its driver.
const applied = try self.rep.apply(.delta, payload);
if (applied == .resync) return .resync;
try self.deltaTaken(payload);
return .state;
},
.pty_mode => return switch (try self.ptyModeChanged(payload)) {
.skip => .skip,
.carry_on => .handled,
},
.scrollback_chunk => return switch (try self.scrollbackPage(payload)) {
.skip => .skip,
.carry_on => .handled,
},
.term_event, .term_modes => {
try self.semanticFrame(frame_type, payload);
return .handled;
},
.term_title => {
try self.titleFrame(payload);
return .handled;
},
.exit_status, .taken_over, .sessions_reply, .end_reply => return .not_mine,
// The correlation, the watermark and the size refusal are the
// Core's (`selectionCopy`); WHERE the text goes is not. At the
// wall the tile that asked is not the focused one every time, and
// the clipboard it copies to belongs to the wall's screen.
.selection_reply => return .not_mine,
// The driver owns local fds and the Core owns the replica, so
// an agent channel is never the Core's: these bytes go to a
// socket, not the grid.
.agent_open, .agent_data, .agent_close => return .not_mine,
// Named rather than swept into the `else`, so giving one a meaning
// is an edit here and not a new switch elsewhere. Each belongs to
// another client conversation or travels the other way.
.stats_reply,
.endpoint_reply,
.cmd_state,
.await_reply,
.status_reply,
.dump_reply,
.attach,
.input,
.resize,
.detach,
.fetch_scrollback,
.stats_req,
.stop_req,
.endpoint_req,
.await_req,
.status_req,
.selection_req,
.sessions_req,
.agent_offer,
.forward_hello,
.forward_open,
.forward_data,
.forward_credit,
.forward_half_close,
.forward_reset,
.forward_ready,
.forward_open_result,
.debug_dump,
.upgrade_req,
.upgrade_reply,
.end_req,
.create_req,
.create_reply,
=> return .skip,
// MsgType is open (`_`): a daemon newer than this client can
// send a type this build has never heard of. Ignoring it is the
// forward-compatible answer and always has been.
_ => return .skip,
}
}
/// Everything that happens to typed bytes on their way to the session:
/// the mouse split, alternate scroll, the scrollback view, and finally
/// the prediction and the input frame.
pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step {
// Who the wheel belongs to is the SESSION's to say: an application
// that asked for mouse reporting gets every mouse byte verbatim, and
// the filter resets so a report straddling the handover is not
// half-eaten. The claim gates it for a second reason — a client whose
// stdin is a PIPE never asked for mouse reports, so nothing it reads
// can be one, and `\x1b[<64;10;5M` in a heredoc is text to send.
var keys = typed;
var wheel: i32 = 0;
// A selection this read finished, asked for below rather than here
// so the ask happens once for the read and outside the branch that
// decides who the mouse belongs to.
var copy: ?select.Range = null;
// What is on screen before this read's reports are read, so the
// repaint below is spent only on a highlight that actually moved.
// A drag reports on every cell the pointer crosses.
const was = self.drag.range();
if (self.claim == .none) {
self.mouse.reset();
self.drag.clear();
} else if (self.semantic.terminal_modes.appMouse()) {
// The application asked for the mouse, so it gets the drag and
// there is no mux selection; Shift+drag stays the terminal's own
// escape hatch. CLEARED, not ignored: a selection made before the
// ask would sit inverted on a screen that is no longer its own.
self.drag.clear();
// What it gets is the report in ITS coordinates. The terminal
// numbers the whole screen, and a tile that does not start at
// the screen's origin is a pane whose application has no row 60
// in its 37: passed through as read, every press in such a tile
// landed outside the application's grid and did nothing (found
// on a live wall, 2026-09-02). Pixel reports are left alone —
// a cell origin cannot come off a pixel — and so are the X10,
// UTF-8 and urxvt spellings, which the filter does not read; an
// application that asks for the mouse without 1006 still gets
// the screen's numbering. The filter's hold stays armed across
// the handover from selection, so a report split across two
// reads is whole for whichever side reads it.
if (!self.semantic.terminal_modes.mouse_sgr_pixels) {
const m = self.mouse.feed(typed, &self.mouse_buf);
keys = self.relocateReports(m, &self.mouse_out);
}
} else {
const m = self.mouse.feed(typed, &self.mouse_buf);
keys = m.forward;
wheel = m.wheel;
copy = self.dragReports(m.events);
}
// This Core owns its own screen and its own thread — a tile's pump
// is what runs `forward` — so the highlight is painted here rather
// than left in shared state for somebody else to draw. The wall's
// keyboard cannot do that and does not (the Core sink does).
try self.paintDragChange(was);
// Drag copies on release, tmux's `copy-pipe-and-cancel` rule and
// the reason there is no copy chord: `Ctrl+Shift+C` cannot reach a
// tty application at all. This thread owns the transport, so the
// ask goes out from where it stands — no relay, unlike the wall's.
if (copy) |r| self.requestSelection(transport, r) catch return .lost;
// Alternate scroll. The alt screen has no scrollback, so the rows this
// notch would move do not exist — and a pager that did not ask for the
// mouse is still what the wheel is pointed at, so the notch becomes
// arrow keys it understands. Without this the notch is consumed and
// dropped. Only at the live view: a client already scrolled into
// history owns its wheel.
// Both modes come off the daemon's sample rather than off this
// client's replica engine, so the rule holds for a client that
// parses no VT of its own.
if (wheel != 0 and self.scroll_rows == 0 and self.semantic.terminal_modes.alt_screen) {
sendAltScroll(transport, wheel, self.semantic.terminal_modes.cursor_keys) catch return .lost;
wheel = 0; // spent on the session, not on our view
}
const scroll_up = "\x1b[5;2~"; // Shift+PageUp
const scroll_dn = "\x1b[6;2~"; // Shift+PageDown
const key_up = std.mem.eql(u8, keys, scroll_up);
const key_dn = std.mem.eql(u8, keys, scroll_dn);
// Rows to move back into history, from both devices at once: a
// chunk can hold a notch and a keystroke, and dropping either would
// be a scroll the user made and did not get. The keys move a
// screenful, the wheel a few rows.
var by: i64 = @as(i64, wheel) * wheel_rows;
if (key_up) by += self.size.rows;
if (key_dn) by -= self.size.rows;
// Whether this read began at the live view, remembered before the
// scroll below can make it false. It decides what a keystroke in
// the same read MEANS: see the exit rule at the bottom.
const was_live = self.scroll_rows == 0;
if (by != 0 or key_dn) {
const next: u32 = if (by > 0)
@min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows)
else
self.scroll_rows -| @as(u32, @intCast(-by));
// Shift+PageDown returns to live even when already there: it is
// the only key that clears an overlay left in scroll mode by a
// reconnect. The wheel has no such arm — a notch at the live view
// would repaint the whole screen for nothing.
if (next == 0 and (self.scroll_rows > 0 or key_dn)) {
self.scroll_rows = 0;
self.overlay.setScrollMode(false);
try self.paintFull();
} else if (next != self.scroll_rows) {
self.scroll_rows = next;
// The cursor is no longer where the user is looking, so a
// prediction painted at it would land in the middle of
// history.
self.overlay.setScrollMode(true);
requestScrollPage(transport, &self.rep, self.scroll_rows, self.size) catch return .lost;
}
}
// A chunk that was nothing but a chord, a scroll key or a wheel
// notch owes the pty nothing.
if (keys.len == 0 or key_up or key_dn) return .ok;
if (self.scroll_rows > 0 and !was_live) {
// Any other key exits scroll mode, swallowed rather than
// forwarded. `was_live` keeps that honest when a notch and a
// keystroke share one read: a key typed before the wheel moved the
// view was typed at the SHELL, and swallowing it loses input.
self.scroll_rows = 0;
self.overlay.setScrollMode(false);
try self.paintFull();
} else {
// Speculate before sending, so the glyph is on screen while the
// keystroke is in flight. The bytes go out whatever the sink says
// — a tile that lost focus still owes its session what was typed
// at it — but an overlay glyph would be graffiti on the new holder.
if (self.beginPaint()) {
defer self.endPaint();
offerKeystroke(self.alloc, &self.overlay, self.rep.grid, keys, self.viewport(), self.out_fd);
}
transport.writeFrame(.input, keys) catch return .lost;
// These keystrokes are lost with the transport, by the same
// policy that drops what is typed while disconnected.
}
return .ok;
}
/// Left button only: middle is the terminal's own paste and right its
/// menu. A plain click is a defined no-op — a tile is the only session on
/// its rect. What comes back is the selection a release FINISHED, and one
/// read can hold several: the last is the answer, as latest-wins says.
fn dragReports(self: *Core, events: []const MouseFilter.Event) ?select.Range {
var done: ?select.Range = null;
for (events) |ev| {
if (ev.button & 0b11 != 0) continue;
const cell: select.Cell = .{ .row = ev.row, .col = ev.col };
switch (ev.kind) {
.press => self.drag.press(cell, self.hitTest(ev)),
.motion => self.drag.motion(cell, self.hitTest(ev)),
.release => switch (self.drag.release()) {
.selection => |r| done = r,
.nothing, .click => {},
},
.wheel => {},
}
}
return done;
}
/// Ask the daemon for the text under a finished selection. Called from
/// `forward`, on the focused tile's pump — the thread that owns this
/// transport. The wall's KEYBOARD may not: it posts the range instead.
pub fn requestSelection(self: *Core, transport: anytype, r: select.Range) !void {
self.sel_id +%= 1;
// Sampled HERE, from the replica the coordinates were resolved
// against, and not from the reply: see `sel_watermark`.
self.sel_watermark = self.rep.history_rows;
self.sel_range = r;
const req = self.semantic.beginSelection(.{
.id = self.sel_id,
.anchor = .{ .row = r.from.row, .col = r.from.col },
.active = .{ .row = r.to.row, .col = r.to.col },
});
try transport.writeFrame(.selection_req, &req);
}
/// What a `selection_reply` is worth, given the selection still held. The
/// caller supplies the range because it reads `Core.drag` under its lock.
/// Correlation is `client_core`'s: a reply answering no pending request is
/// `.ignored` there and never reaches the tests below.
pub fn selectionCopy(self: *Core, payload: []const u8, held: ?select.Range) Copy {
const reply = switch (self.semantic.receive(.selection_reply, payload)) {
.reply => |r| r.selection,
else => return .none,
};
// The eviction test. STRICTLY lower, because ordinary output raises
// the count without moving row zero — an equality test would refuse
// every copy taken from a session that is still writing.
if (reply.history_rows < self.sel_watermark) return .none;
// The highlight the ask was taken from is gone or has moved on, so
// whatever came back describes rows nobody is looking at.
if (!std.meta.eql(held, self.sel_range)) return .none;
switch (reply.status) {
.ok => {},
.too_large => return .too_large,
.invalid, .unavailable => return .none,
}
if (reply.text.len == 0) return .none;
// The daemon sends up to `selection_text_max` (1 MiB) and OSC 52 stops
// at `clipboard_base64_max` (64 KiB) of base64: everything between the
// two round-trips `.ok` and copies nothing at all.
if (std.base64.standard.Encoder.calcSize(reply.text.len) > proto.clipboard_base64_max)
return .too_large;
return .{ .text = reply.text };
}
/// The read rebuilt for an application that owns the mouse: the keys as
/// they came, and each report re-spelled with the pane's origin taken
/// off its coordinates. A coordinate outside the pane CLAMPS to its
/// edge rather than dropping the report, as a terminal reports a drag
/// that left its window — dropping would leave the application holding
/// a button the hand released over a neighbour. The edge is what the
/// pane SHOWS, the smaller of the grid and the clip, like `hitTest`.
fn relocateReports(self: *Core, m: MouseFilter.Out, out: []u8) []const u8 {
const rows = @min(@as(u16, @intCast(self.rep.grid.rows)), self.size.rows);
const cols = @min(@as(u16, @intCast(self.rep.grid.cols)), self.size.cols);
var len: usize = 0;
var from: usize = 0;
for (m.events) |ev| {
@memcpy(out[len..][0 .. ev.at - from], m.forward[from..ev.at]);
len += ev.at - from;
from = ev.at;
const col = @min(ev.col -| self.col_off, cols -| 1);
const row = @min(ev.row -| self.row_off, rows -| 1);
const spelt = std.fmt.bufPrint(out[len..], "\x1b[<{d};{d};{d}{c}", .{
ev.button,
col + 1,
row + 1,
@as(u8, if (ev.kind == .release) 'm' else 'M'),
}) catch unreachable;
len += spelt.len;
}
@memcpy(out[len..][0 .. m.forward.len - from], m.forward[from..]);
len += m.forward.len - from;
return out[0..len];
}
/// Which line of this session a report landed on, or null. The mapping is
/// trivial, which is why selection lives in the Core: a terminal cell is a
/// grid cell less the tile's origin, plus the history under it.
fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit {
// Scrolled back, nothing here is addressable: `renderScrollback`
// paints decoded history rows this Core does not hold. Refused rather
// than half-answered, or the coordinates name the live view instead.
if (self.scroll_rows > 0) return null;
// A tile paints at `row_off`/`col_off`, so a terminal cell is a grid
// cell only after the origin comes off — on BOTH axes. Above or left
// of the tile is a neighbour's cell, not a cell of this grid.
if (ev.row < self.row_off or ev.col < self.col_off) return null;
const grow = ev.row - self.row_off;
const gcol = ev.col - self.col_off;
const grid_rows: u16 = @intCast(self.rep.grid.rows);
const grid_cols: u16 = @intCast(self.rep.grid.cols);
// Rows past the grid (a terminal taller than the daemon's grid)
// hold no session line: `renderClipped` never painted them.
if (grow >= @min(grid_rows, self.size.rows)) return null;
return .{
.tile = focus_tile,
.row = self.rep.history_rows + grow,
// Columns CLAMP rather than refuse: the right edge is where a hand
// overshoots, and a round trip spent being told `.invalid` is a
// copy the user does not get. Clamped to what the tile SHOWS.
.col = @min(gcol, @min(grid_cols, self.size.cols) -| 1),
};
}
/// Go back to the live view without painting it — a resync's own
/// repaint is what arrives.
pub fn dropScrollView(self: *Core) void {
self.scroll_rows = 0;
// `flush()` leaves the mode bit alone, so nothing else clears it:
// an overlay left suppressing has no page to suppress for, and
// only Shift+PageDown would ever turn it back on.
self.overlay.setScrollMode(false);
}
/// A new transport is up and an attach frame has gone out on it. Only the
/// driver knows, so the Replica's contract makes this clear the caller's.
/// The overlay's contents were predicted against a connection that no
/// longer exists — dropping them is no accusation, so counters hold.
pub fn reattached(self: *Core) void {
// The absolute row space is counted from the oldest row the daemon
// retains, and a resync renames it outright — so a highlight kept
// across one is an inversion over rows nobody selected.
self.drag.clear();
self.rep.state_since_attach = false;
self.repaint_after_resync = true;
self.overlay.flush();
}
};
test "interact: a chord in one read detaches and forwards what preceded it" {
var f: PrefixFilter = .{};
var chunk = "ab\x1cd".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.detach, out.action);
try std.testing.expectEqualStrings("ab", out.forward);
}
test "interact: a doubled prefix detaches" {
var f: PrefixFilter = .{};
var chunk = "\x1c\x1c".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.detach, out.action);
try std.testing.expectEqualStrings("", out.forward);
}
test "interact: a chord split across two reads is still one chord" {
var f: PrefixFilter = .{};
var first = "ab\x1c".*;
const a = f.feed(&first);
try std.testing.expectEqual(PrefixFilter.Action.none, a.action);
try std.testing.expectEqualStrings("ab", a.forward);
var second = "dz".*;
const b = f.feed(&second);
try std.testing.expectEqual(PrefixFilter.Action.detach, b.action);
try std.testing.expectEqualStrings("", b.forward);
}
test "interact: an unknown command key is swallowed with its prefix" {
var f: PrefixFilter = .{};
var chunk = "a\x1czb".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("ab", out.forward);
// Back to normal: the next `d` is an ordinary keystroke, not a command.
var after = "d".*;
const next = f.feed(&after);
try std.testing.expectEqual(PrefixFilter.Action.none, next.action);
try std.testing.expectEqualStrings("d", next.forward);
}
test "asking: printable bytes accumulate into the answer" {
var f: PrefixFilter = .{};
f.askOpen(.secret);
var chunk = "hunter2".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("hunter2", f.askLine());
}
test "asking: Enter answers with the line, and backspace unspells it" {
var f: PrefixFilter = .{};
f.askOpen(.secret);
var typed = "hunterX\x7f2\r".*;
const out = f.feed(&typed);
switch (out.action) {
.ask_answer => |a| try std.testing.expectEqualStrings("hunter2", a),
else => {
std.debug.print("Enter on a prompt gave .{s}, not the line\n", .{@tagName(out.action)});
return error.TestUnexpectedResult;
},
}
// Closed on the answer: the next byte is the session's again.
try std.testing.expect(!f.asking);
}
test "asking: an EMPTY Enter is an answer, not a cancel" {
// A key with no passphrase asks anyway, and ssh takes "" as the try.
// Reporting `.none` here would leave ssh blocked on a socket forever
// with the popup already gone.
var f: PrefixFilter = .{};
f.askOpen(.secret);
var enter = "\r".*;
const out = f.feed(&enter);
switch (out.action) {
.ask_answer => |a| try std.testing.expectEqualStrings("", a),
else => {
std.debug.print("an empty Enter gave .{s}, not an empty answer\n", .{@tagName(out.action)});
return error.TestUnexpectedResult;
},
}
}
test "asking: Esc and Ctrl-C both decline, and Esc eats an arrow's tail" {
var f: PrefixFilter = .{};
f.askOpen(.confirm);
var esc = "\x1b[A".*;
const out = f.feed(&esc);
try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
// The `[A` left with the Esc. Forwarded, it would arrive at whatever
// session had the focus as a cursor key nobody pressed.
try std.testing.expectEqualStrings("", out.forward);
f.askOpen(.confirm);
var ctrlc = "\x03".*;
try std.testing.expectEqual(PrefixFilter.Action.ask_decline, f.feed(&ctrlc).action);
try std.testing.expect(!f.asking);
}
test "asking: NO byte reaches a session while ssh is waiting" {
// The whole reason the mode eats prose: the bytes being typed at a
// password box are a password, and one that reached a shell would be
// in that shell's history in the clear.
var f: PrefixFilter = .{};
f.askOpen(.secret);
var chunk = "abc\x03".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.ask_decline, out.action);
try std.testing.expectEqual(@as(usize, 0), out.forward.len);
}
test "asking: a prompt over the picker's editor keeps the half-typed spelling" {
// ssh does not wait for the picker to close, so the two layers are live
// at once. Sharing one buffer would answer the password prompt and lose
// the host the user was halfway through naming.
var f: PrefixFilter = .{};
var open_editor = "\x1csa--sock /run/a".*;
_ = f.feed(&open_editor);
try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());
f.askOpen(.secret);
var pw = "s3cret\r".*;
const out = f.feed(&pw);
switch (out.action) {
.ask_answer => |a| try std.testing.expectEqualStrings("s3cret", a),
else => return error.TestUnexpectedResult,
}
// ...and the editor is still open, still holding what was typed at it.
try std.testing.expect(f.prompting);
try std.testing.expectEqualStrings("--sock /run/a", f.promptLine());
var rest = ".sock\r".*;
const done = f.feed(&rest);
switch (done.action) {
.add_tile => |t| try std.testing.expectEqualStrings("--sock /run/a.sock", t),
else => return error.TestUnexpectedResult,
}
}
test "asking: an answer longer than the cap is cut, never overruns" {
var f: PrefixFilter = .{};
f.askOpen(.secret);
var long: [askpass.answer_max + 8]u8 = @splat('x');
const out = f.feed(&long);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqual(askpass.answer_max, f.askLine().len);
}
test "interact: bytes with no prefix pass through untouched" {
var f: PrefixFilter = .{};
var chunk = "hello\x1b[A".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("hello\x1b[A", out.forward);
}
test "interact: two chords in one buffer are consumed independently" {
var f: PrefixFilter = .{};
var chunk = "\x1czq".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("q", out.forward);
var chunk2 = "\x1cz\x1cd".*;
const out2 = f.feed(&chunk2);
try std.testing.expectEqual(PrefixFilter.Action.detach, out2.action);
try std.testing.expectEqualStrings("", out2.forward);
}
test "interact: hjkl under the prefix answer directional focus" {
var f: PrefixFilter = .{};
var buf = "\x1chZZ".*;
const out = f.feed(&buf);
try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .left }, out.action);
try std.testing.expectEqual(@as(usize, 0), out.forward.len);
}
test "interact: the split chords and fullscreen answer, backslash aliases the pipe" {
inline for (.{
.{ "\x1c|", PrefixFilter.Action.split_right },
.{ "\x1c\\", PrefixFilter.Action.split_right },
.{ "\x1c-", PrefixFilter.Action.split_below },
.{ "\x1cf", PrefixFilter.Action.fullscreen },
}) |case| {
var f: PrefixFilter = .{};
var buf = case[0].*;
try std.testing.expectEqual(case[1], f.feed(&buf).action);
}
}
test "interact: resize mode is sticky across reads and Esc leaves it silently" {
var f: PrefixFilter = .{};
var enter = "\x1cr".*;
try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&enter).action);
var grow = "l".*;
try std.testing.expectEqual(PrefixFilter.Action{ .resize = .right }, f.feed(&grow).action);
var again = "j".*;
try std.testing.expectEqual(PrefixFilter.Action{ .resize = .down }, f.feed(&again).action);
var esc = "\x1b".*;
const out = f.feed(&esc);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqual(@as(usize, 0), out.forward.len);
var plain = "l".*;
try std.testing.expectEqual(@as(usize, 1), f.feed(&plain).forward.len);
}
test "interact: prose ends resize mode and goes to the session" {
var f: PrefixFilter = .{};
var enter = "\x1cr".*;
_ = f.feed(&enter);
var typed = "vim".*;
const out = f.feed(&typed);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("vim", out.forward);
}
test "interact: the prompt appends, backspaces, and Enter emits add_tile" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&open).action);
try std.testing.expect(f.prompting);
var typed = "boxX\x7f\r".*;
const out = f.feed(&typed);
try std.testing.expectEqualStrings("box", out.action.add_tile);
try std.testing.expectEqualStrings("", out.forward);
try std.testing.expect(!f.prompting);
}
test "interact: Esc cancels the prompt and drops the rest of the read" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
var line = "ab".*;
_ = f.feed(&line);
// An arrow key is Esc [ A in one read: the Esc cancels, the [A must
// neither reach the session nor survive in the line.
var esc = "\x1b[A".*;
const out = f.feed(&esc);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("", out.forward);
try std.testing.expect(!f.prompting);
// Back in the picker, not in the session: the editor is a layer over
// the popup, so its cancel undoes one layer and not two.
try std.testing.expect(f.picking);
}
test "interact: Ctrl-C cancels the prompt like Esc" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
var line = "ab".*;
_ = f.feed(&line);
// The reflex cancel. It ends the read like Esc's does, so one answer
// covers both — but for its own reason: Esc must swallow an arrow
// key's tail, and Ctrl-C has no tail to swallow.
var ctrl_c = "\x03".*;
const out = f.feed(&ctrl_c);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expectEqualStrings("", out.forward);
try std.testing.expect(!f.prompting);
// Not typed at the shell either: the byte was aimed at the prompt.
try std.testing.expect(f.picking);
}
test "interact: a prompt split across reads is still one line" {
var f: PrefixFilter = .{};
var a = "\x1c".*;
_ = f.feed(&a);
var b = "s".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&b).action);
var e = "a".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&e).action);
var b2 = "--sock ".*;
try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&b2).action);
var c = "/tmp/x\n".*;
try std.testing.expectEqualStrings("--sock /tmp/x", f.feed(&c).action.add_tile);
}
test "interact: the prompt line stops at prompt_max" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
var many: [PrefixFilter.prompt_max + 1]u8 = undefined;
@memset(&many, 'a');
_ = f.feed(&many);
try std.testing.expectEqual(PrefixFilter.prompt_max, f.promptLine().len);
var enter = "\r".*;
try std.testing.expectEqual(PrefixFilter.prompt_max, f.feed(&enter).action.add_tile.len);
}
test "interact: an empty Enter cancels the prompt, it is not an add_tile" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
var enter = "\r".*;
const out = f.feed(&enter);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
try std.testing.expect(!f.prompting);
}
test "interact: bytes typed behind the prompt's Enter are dropped" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
var typed = "hb\rtail".*;
const out = f.feed(&typed);
try std.testing.expectEqualStrings("hb", out.action.add_tile);
try std.testing.expectEqualStrings("", out.forward);
// The picker took the keyboard back, so `q` is a row key and not a
// letter for the shell.
var next = "q".*;
try std.testing.expectEqualStrings("", f.feed(&next).forward);
}
test "interact: backspace on an empty prompt line stays empty" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
// A backspace past the start is a no-op, not an underflow: the length
// saturates, and the prompt is still open for the spelling.
var rubout = "\x7f\x7f".*;
_ = f.feed(&rubout);
try std.testing.expectEqual(@as(usize, 0), f.promptLine().len);
try std.testing.expect(f.prompting);
var typed = "a\r".*;
try std.testing.expectEqualStrings("a", f.feed(&typed).action.add_tile);
}
test "interact: control bytes are ignored by the prompt" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
_ = f.feed(&open);
// A `\x1c` typed INSIDE the prompt is dropped like any other control
// byte — it does not re-arm the prefix and take the next key as a chord.
// 0x03 is not among them any more; it cancels, and has its own test.
var typed = "a\x09\x1cb\r".*;
try std.testing.expectEqualStrings("ab", f.feed(&typed).action.add_tile);
}
test "interact: prose before the prefix is forwarded, the prompt's Enter still ends the read" {
var f: PrefixFilter = .{};
var read = "ab\x1csa".*;
try std.testing.expectEqualStrings("ab", f.feed(&read).forward);
var typed = "x\rzz".*;
const out = f.feed(&typed);
try std.testing.expectEqualStrings("", out.forward);
try std.testing.expectEqualStrings("x", out.action.add_tile);
}
fn refuseSink(_: ?*anyopaque) bool {
return false;
}
test "claimTerminal: a sink that refuses leaves the claim unheld, so the caller must retry" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
defer core.deinit();
core.is_tty = true;
// The picker refuses every tile paint while its popup owns the screen, and
// a claim writes the session's modes THROUGH that sink. False and `.none`
// together tell the pump to re-arm; read as "already held", the tile stays
// focused owning no terminal until the focus moves away and back.
core.sink = .{ .begin = refuseSink };
try std.testing.expect(!core.claimTerminal());
try std.testing.expectEqual(Claim.none, core.claim);
var buf: [64]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), std.posix.read(p[0], &buf) catch 0);
// ...and the same call lands once the sink lets it through.
core.sink = .{};
try std.testing.expect(core.claimTerminal());
try std.testing.expectEqual(Claim.session, core.claim);
}
test "interact: Ctrl-\\ s opens the host picker" {
var f: PrefixFilter = .{};
var buf = "\x1cs".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&buf).action);
try std.testing.expect(f.picking);
}
test "interact: the picker's move keys are jk and the arrows" {
const cases = [_]struct { []const u8, i8 }{
.{ "j", 1 },
.{ "k", -1 },
.{ "\x1b[B", 1 },
.{ "\x1b[A", -1 },
// Application cursor mode sends the same two keys as ESC O A/B,
// and a terminal may be in it when the picker opens.
.{ "\x1bOB", 1 },
.{ "\x1bOA", -1 },
};
for (cases) |c| {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var keys: [8]u8 = undefined;
@memcpy(keys[0..c[0].len], c[0]);
const out = f.feed(keys[0..c[0].len]);
try std.testing.expectEqual(PrefixFilter.Action{ .pick_move = c[1] }, out.action);
try std.testing.expect(f.picking);
}
}
test "interact: a digit selects a picker row and leaves the picker open" {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var three = "3".*;
try std.testing.expectEqual(PrefixFilter.Action{ .pick_select = 3 }, f.feed(&three).action);
try std.testing.expect(f.picking);
}
test "interact: c births on the selected host and closes the picker; Enter no longer does" {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var key = "c".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&key).action);
try std.testing.expect(!f.picking);
// Enter is the descend key now: it opens the selected host's sessions
// and births nothing, so a birth is `c` and only `c`.
for ([_][]const u8{ "\r", "\n" }) |ent| {
var g: PrefixFilter = .{};
_ = g.feed(&open);
var keys: [2]u8 = undefined;
@memcpy(keys[0..ent.len], ent);
try std.testing.expectEqual(PrefixFilter.Action.pick_enter, g.feed(keys[0..ent.len]).action);
try std.testing.expect(g.picking);
}
}
test "interact: picker levels: Enter on a host opens its sessions, Esc backs out a level, Enter on a session closes with pick_enter" {
var f = PrefixFilter{};
var open = [_]u8{ detach_key, 's' };
try std.testing.expectEqual(PrefixFilter.Action.pick_open, f.feed(&open).action);
try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
var enter = [_]u8{'\r'};
try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
try std.testing.expect(f.picking);
try std.testing.expectEqual(PrefixFilter.PickLevel.sessions, f.pick_level);
// Esc at the session level backs out one level rather than closing:
// the list of machines is where the user came from.
var esc = [_]u8{0x1b};
try std.testing.expectEqual(PrefixFilter.Action.pick_back, f.feed(&esc).action);
try std.testing.expect(f.picking);
try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
_ = f.feed(&enter);
try std.testing.expectEqual(PrefixFilter.Action.pick_enter, f.feed(&enter).action);
try std.testing.expect(!f.picking);
// The level goes home with the close, so the next open is a host list.
try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
// ...and a second Esc, back at the host level, closes.
_ = f.feed(&open);
try std.testing.expectEqual(PrefixFilter.Action.pick_close, f.feed(&esc).action);
try std.testing.expect(!f.picking);
}
test "interact: picker levels: x forgets at the host level and ends at the session level; c births at either; every byte stays the popup's" {
var f = PrefixFilter{};
var open = [_]u8{ detach_key, 's' };
_ = f.feed(&open);
var x = [_]u8{'x'};
try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&x).action);
try std.testing.expect(!f.picking);
_ = f.feed(&open);
var enter = [_]u8{'\r'};
_ = f.feed(&enter);
const ended = f.feed(&x);
try std.testing.expectEqual(PrefixFilter.Action.pick_end, ended.action);
try std.testing.expectEqual(@as(usize, 0), ended.forward.len);
try std.testing.expect(f.picking); // the popup stays up to show the count or the end
var c = [_]u8{'c'};
try std.testing.expectEqual(PrefixFilter.Action.pick_birth, f.feed(&c).action);
try std.testing.expect(!f.picking);
try std.testing.expectEqual(PrefixFilter.PickLevel.hosts, f.pick_level);
}
test "interact: x forgets the selected host and closes the picker" {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var key = "x".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_forget, f.feed(&key).action);
// Closed, because a forget re-shapes the wall under the popup: the
// host's tiles leave, and the user is owed the wall that produced.
try std.testing.expect(!f.picking);
}
test "interact: a opens the spelling editor inside the picker" {
var f: PrefixFilter = .{};
var open = "\x1csa".*;
try std.testing.expectEqual(PrefixFilter.Action.pick_add_open, f.feed(&open).action);
try std.testing.expect(f.prompting);
// Still the picker's: Enter adds the host and hands the keyboard back
// to the rows, so the editor is a layer over the popup and not a mode
// beside it.
try std.testing.expect(f.picking);
var typed = "box\r".*;
try std.testing.expectEqualStrings("box", f.feed(&typed).action.add_tile);
try std.testing.expect(!f.prompting);
try std.testing.expect(f.picking);
}
test "interact: Esc in the editor returns to the picker, not to the session" {
var f: PrefixFilter = .{};
var open = "\x1csazz".*;
_ = f.feed(&open);
var esc = "\x1b".*;
try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&esc).action);
try std.testing.expect(!f.prompting);
try std.testing.expect(f.picking);
}
test "interact: Esc, s and Ctrl-C all close the picker" {
// Ctrl-C is the reflex cancel and closes the popup like Esc, for the
// reason it cancels the editor: a user reaching for it wants OUT, and
// a mode that eats every byte must not eat the way out of itself.
for ([_][]const u8{ "\x1b", "s", "\x03" }) |key| {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var keys: [1]u8 = undefined;
keys[0] = key[0];
try std.testing.expectEqual(PrefixFilter.Action.pick_close, f.feed(&keys).action);
try std.testing.expect(!f.picking);
}
}
test "interact: a mouse report and an arrow the popup has no key for are not the Esc that closes it" {
// The prefix filter runs ahead of the mouse filter, and nothing releases
// the focused session's mouse modes while the box is up, so a wheel
// notch and a click both arrive here as an Esc with a tail.
for ([_][]const u8{ "\x1b[<64;10;5M", "\x1b[M !!", "\x1b[C", "\x1b[H", "\x1bOP" }) |seq| {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var keys: [16]u8 = undefined;
@memcpy(keys[0..seq.len], seq);
const out = f.feed(keys[0..seq.len]);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
if (!f.picking) return error.WheelClosedThePicker;
// The tail went with it: left in the chunk, a report's digits are
// row selections and its letters are the popup's own keys.
try std.testing.expectEqualStrings("", out.forward);
}
}
test "interact: the picker eats prose, so a key never reaches a session" {
var f: PrefixFilter = .{};
var open = "\x1cs".*;
_ = f.feed(&open);
var prose = "hello".*;
const out = f.feed(&prose);
try std.testing.expectEqualStrings("", out.forward);
try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
// The `\n` behind it is the picker's own descend key, and the letters
// ahead of it went nowhere at all — no shell saw one.
var enter = "\n".*;
const done = f.feed(&enter);
try std.testing.expectEqualStrings("", done.forward);
try std.testing.expectEqual(PrefixFilter.Action.pick_enter, done.action);
}
test "interact: the colon chord is gone, the picker's a replaced it" {
var f: PrefixFilter = .{};
var buf = "\x1c:".*;
try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&buf).action);
try std.testing.expect(!f.prompting);
// ...and the byte is eaten like any other unbound chord key, never
// handed to the session as a stray colon.
try std.testing.expectEqualStrings("", f.feed(&buf).forward);
}
test "interact: l is not last-session any more" {
var f: PrefixFilter = .{};
var buf = "\x1cl".*;
try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .right }, f.feed(&buf).action);
}
test "interact: a wheel report becomes a scroll and never reaches the pty" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// Button 64 press: wheel up, into history. Button 65: wheel down.
const up = f.feed("\x1b[<64;10;5M", &out);
try std.testing.expectEqual(@as(i32, 1), up.wheel);
try std.testing.expectEqualStrings("", up.forward);
const dn = f.feed("\x1b[<65;10;5M", &out);
try std.testing.expectEqual(@as(i32, -1), dn.wheel);
try std.testing.expectEqualStrings("", dn.forward);
// A terminal sends a burst when the wheel is spun; they add up rather
// than the last one winning, or a fast spin would move one notch.
const burst = f.feed("\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<64;1;1M\x1b[<65;1;1M", &out);
try std.testing.expectEqual(@as(i32, 2), burst.wheel);
try std.testing.expectEqualStrings("", burst.forward);
// Ctrl+wheel is still a wheel: no font-size change exists here to claim it.
const ctrl = f.feed("\x1b[<80;1;1M", &out);
try std.testing.expectEqual(@as(i32, 1), ctrl.wheel);
}
test "interact: alternate scroll spells its arrows the way the session reads them" {
// Normal cursor keys: the CSI form. Application mode (DECCKM, what
// `less` and every curses program set): the SS3 form. A client that
// sent the CSI form to an application-mode reader would scroll nothing
// and say nothing — measured on `less +G` before this existed.
try std.testing.expectEqualStrings("\x1b[A", altScrollSeq(1, false));
try std.testing.expectEqualStrings("\x1b[B", altScrollSeq(-1, false));
try std.testing.expectEqualStrings("\x1bOA", altScrollSeq(1, true));
try std.testing.expectEqualStrings("\x1bOB", altScrollSeq(-1, true));
}
test "interact: the wheel becomes arrows from the sampled modes, not from an engine" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: InputTransport = .{};
// The daemon says the alternate screen is up and DECCKM is set. No
// escape is fed to the replica here on purpose: the rule must hold for
// a client that parses no VT at all.
core.semantic.terminal_modes = .{
.bracketed_paste = false,
.alt_screen = true,
.cursor_keys = true,
};
try mouse(&core, &tr, 64, 3, 2, 'M');
try std.testing.expectEqualStrings("\x1bOA" ** wheel_rows, tr.take());
try mouse(&core, &tr, 65, 3, 2, 'M');
try std.testing.expectEqualStrings("\x1bOB" ** wheel_rows, tr.take());
// DECCKM off with the alternate screen still up is the CSI spelling.
core.semantic.terminal_modes.cursor_keys = false;
try mouse(&core, &tr, 64, 3, 2, 'M');
try std.testing.expectEqualStrings("\x1b[A" ** wheel_rows, tr.take());
// The inverse: the modes say the primary screen, so the notch is this
// client's own view to move and the session reads nothing at all.
core.semantic.terminal_modes.alt_screen = false;
try mouse(&core, &tr, 64, 3, 2, 'M');
try std.testing.expectEqualStrings("", tr.take());
}
test "interact: clicks, drags and releases are discarded rather than typed at the shell" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// Left press, left release, drag (motion bit 32 with button 0), and the
// wheel's horizontal cousins. Nobody asked for any of them — with no
// application wanting the mouse there is nobody to send them to.
for ([_][]const u8{
"\x1b[<0;40;12M",
"\x1b[<0;40;12m",
"\x1b[<32;41;12M",
"\x1b[<66;1;1M",
"\x1b[<67;1;1M",
"\x1b[<64;1;1m",
}) |report| {
const r = f.feed(report, &out);
try std.testing.expectEqual(@as(i32, 0), r.wheel);
try std.testing.expectEqualStrings("", r.forward);
}
}
test "interact: a wheel report split across reads is still one report" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// The split is after the `ESC [ <` that starts the hold — the only
// place the filter holds, deliberately (see MouseFilter).
const a = f.feed("typed\x1b[<64;", &out);
try std.testing.expectEqual(@as(i32, 0), a.wheel);
try std.testing.expectEqualStrings("typed", a.forward);
const b = f.feed("10;5M", &out);
try std.testing.expectEqual(@as(i32, 1), b.wheel);
try std.testing.expectEqualStrings("", b.forward);
}
test "interact: keystrokes survive the mouse filter, in order and unheld" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// A bare Escape is forwarded on the read it arrived in. This is the
// property the filter gives up completeness for: held, it would strand
// every Escape in vim until the next keystroke.
const esc = f.feed("\x1b", &out);
try std.testing.expectEqualStrings("\x1b", esc.forward);
// An arrow key is `ESC [ A`: it starts like a report and is not one.
const arrow = f.feed("\x1b[A", &out);
try std.testing.expectEqualStrings("\x1b[A", arrow.forward);
// Typing either side of a wheel notch keeps its order.
const mixed = f.feed("ab\x1b[<64;1;1Mcd", &out);
try std.testing.expectEqual(@as(i32, 1), mixed.wheel);
try std.testing.expectEqualStrings("abcd", mixed.forward);
// The exact chunk the scroll block's `was_live` rule is about: a notch
// and a keystroke in one read. The filter must hand back BOTH — the
// notch to move the view and the `x` to reach the pty — because a
// filter that dropped either would make that rule undecidable.
const both = f.feed("\x1b[<64;1;1Mx", &out);
try std.testing.expectEqual(@as(i32, 1), both.wheel);
try std.testing.expectEqualStrings("x", both.forward);
}
test "interact: a button press becomes an event, still never reaching the pty" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// SGR press of button 0 at the terminal's column 40, row 12. The
// reports the filter used to answer with a wheel notch or silence are
// the same bytes; only what it can SAY about them is new.
const r = f.feed("\x1b[<0;40;12M", &out);
// The old contract, unchanged: a report is not input.
try std.testing.expectEqualStrings("", r.forward);
try std.testing.expectEqual(@as(i32, 0), r.wheel);
try std.testing.expectEqual(@as(usize, 1), r.events.len);
try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[0].kind);
try std.testing.expectEqual(@as(u16, 0), r.events[0].button);
// Zero-based here, one-based on the wire: every coordinate this client
// owns is zero-based (grid rows, `Stripe.top`), so the conversion
// happens once, at the edge, rather than at each use.
try std.testing.expectEqual(@as(u16, 39), r.events[0].col);
try std.testing.expectEqual(@as(u16, 11), r.events[0].row);
}
test "interact: a wheel report is a notch and a wheel event, never a press" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// Button 64 is wheel-up. It has always been a notch; what it must not
// ALSO be is a press, or a driver would scroll the view and move a
// selection endpoint on the same turn of the wheel. It is an event of
// its own kind so the driver that hands the mouse to an application
// can re-spell it in the pane's coordinates like any other report.
const up = f.feed("\x1b[<64;1;1M", &out);
try std.testing.expectEqual(@as(i32, 1), up.wheel);
try std.testing.expectEqual(@as(usize, 1), up.events.len);
try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, up.events[0].kind);
// 65 is wheel-down, 66/67 the horizontal pair this client does not
// handle — all wheel, none of them a press or a motion.
for ([_][]const u8{ "\x1b[<65;1;1M", "\x1b[<66;1;1M", "\x1b[<67;1;1M" }) |seq| {
const r = f.feed(seq, &out);
try std.testing.expectEqual(@as(usize, 1), r.events.len);
try std.testing.expectEqual(MouseFilter.Event.Kind.wheel, r.events[0].kind);
}
}
test "interact: a wheel turned mid-drag moves no endpoint of the selection" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// The wheel is an event now, so this is where the old filter-level
// silence has to be said again: the drag's far end is where the hand
// last moved, not where the wheel turned.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
try mouse(&core, &tr, 64, 12, 4, 'M');
try std.testing.expectEqual(@as(u16, 6), core.drag.range().?.to.col);
try std.testing.expectEqual(@as(u32, 1), core.drag.range().?.to.row);
}
test "interact: one drag is press, motion and release, in the order typed" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// A whole drag can arrive in one read — the terminal writes a report
// per event and the pty delivers up to 16 KiB — so the filter has to
// hand back all three, ordered, rather than the last one it saw.
const r = f.feed("\x1b[<0;1;1M\x1b[<32;3;1M\x1b[<0;5;2m", &out);
try std.testing.expectEqualStrings("", r.forward);
try std.testing.expectEqual(@as(usize, 3), r.events.len);
try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[0].kind);
try std.testing.expectEqual(@as(u16, 0), r.events[0].col);
// Bit 5 (32) marks motion-with-button-held. Same button, still down.
try std.testing.expectEqual(MouseFilter.Event.Kind.motion, r.events[1].kind);
try std.testing.expectEqual(@as(u16, 2), r.events[1].col);
// A release says so with its final `m`, whatever the button bits are.
try std.testing.expectEqual(MouseFilter.Event.Kind.release, r.events[2].kind);
try std.testing.expectEqual(@as(u16, 4), r.events[2].col);
try std.testing.expectEqual(@as(u16, 1), r.events[2].row);
}
test "interact: an event knows where it fell among the keys" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// `forward` flattens the keys and drops the reports, so two flat lists
// cannot say whether the click came before or after the Enter — at the
// wall, the difference between two tiles getting the focus.
const key_first = f.feed("\r\x1b[<0;3;2M", &out);
try std.testing.expectEqualStrings("\r", key_first.forward);
try std.testing.expectEqual(@as(usize, 1), key_first.events.len);
// One byte of `forward` was already emitted when this report finished.
try std.testing.expectEqual(@as(usize, 1), key_first.events[0].at);
// Same two things, the other way round: nothing had been emitted yet.
const report_first = f.feed("\x1b[<0;3;2M\r", &out);
try std.testing.expectEqualStrings("\r", report_first.forward);
try std.testing.expectEqual(@as(usize, 1), report_first.events.len);
try std.testing.expectEqual(@as(usize, 0), report_first.events[0].at);
}
test "interact: a chunk packed with the shortest report fills, and does not overrun" {
var f: MouseFilter = .{};
// The cap this pins is the filter's totality claim, so the input is the
// worst case: a whole stdin chunk of the SHORTEST complete report there
// is. `\x1b[<0;1;1M` is NINE bytes — counted, because ten is 181 short.
const shortest = "\x1b[<0;1;1M";
try std.testing.expectEqual(@as(usize, 9), shortest.len);
var in: [stdin_chunk]u8 = undefined;
var i: usize = 0;
while (i + shortest.len <= in.len) : (i += shortest.len) {
@memcpy(in[i..][0..shortest.len], shortest);
}
// The tail that does not fit a whole report is padding no report claims.
@memset(in[i..], 'z');
var out: [stdin_chunk + MouseFilter.max_held]u8 = undefined;
const r = f.feed(&in, &out);
try std.testing.expectEqual(stdin_chunk / shortest.len, r.events.len);
try std.testing.expect(r.events.len <= MouseFilter.max_events);
// Every one decoded, none of them a wheel, and the padding still typed.
try std.testing.expectEqual(MouseFilter.Event.Kind.press, r.events[r.events.len - 1].kind);
try std.testing.expectEqualStrings(in[i..], r.forward);
}
test "interact: a report a terminal malformed is dropped, not decoded" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// Column and row are one-based on the wire. A zero is nonsense, and
// the decrement that makes it zero-based would wrap it to 65535 — a
// coordinate large enough to pass every later bound check by being
// enormous rather than by being right.
try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;0;1M", &out).events.len);
try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;1;0M", &out).events.len);
// Short of its three parameters, and unparseable in the first.
try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<0;1M", &out).events.len);
try std.testing.expectEqual(@as(usize, 0), f.feed("\x1b[<;1;1M", &out).events.len);
}
test "interact: a candidate that turns out not to be a report is given back whole" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// `ESC [ <` with a letter behind it is not a mouse report — nothing may
// be swallowed on the strength of a guess.
const broken = f.feed("\x1b[<12x", &out);
try std.testing.expectEqual(@as(i32, 0), broken.wheel);
try std.testing.expectEqualStrings("\x1b[<12x", broken.forward);
// Held bytes from a previous read come back ahead of this read's, in
// the order they were typed.
const held = f.feed("\x1b[<9", &out);
try std.testing.expectEqualStrings("", held.forward);
const rest = f.feed("q", &out);
try std.testing.expectEqualStrings("\x1b[<9q", rest.forward);
// A candidate longer than any real report is abandoned, not held for
// ever.
var long: [MouseFilter.max_held * 2]u8 = undefined;
@memcpy(long[0..3], "\x1b[<");
@memset(long[3..], '1');
var big_out: [long.len + MouseFilter.max_held]u8 = undefined;
const over = f.feed(&long, &big_out);
try std.testing.expectEqual(@as(i32, 0), over.wheel);
try std.testing.expectEqualStrings(&long, over.forward);
}
test "interact: resetting the filter drops a half-read report" {
var f: MouseFilter = .{};
var out: [64]u8 = undefined;
// What the handover to a mouse-hungry application does: the rest of the
// report belongs to the application, so the head of it must not be
// pushed back into its input.
_ = f.feed("\x1b[<64;", &out);
f.reset();
const after = f.feed("hi", &out);
try std.testing.expectEqualStrings("hi", after.forward);
}
test "interact: Ctrl-\\ c asks for a new session and ends the chunk" {
var f: PrefixFilter = .{};
var chunk = "ab\x1ccz".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.new_session, out.action);
// "ab" was typed at the session we are leaving and has already been
// sent; the "z" behind the chord was typed at it too and is dropped,
// because the only sessions left to put it in are the wrong ones.
try std.testing.expectEqualStrings("ab", out.forward);
}
test "interact: Ctrl-\\ n and Ctrl-\\ p step the session ring" {
var f: PrefixFilter = .{};
var fwd = "ab\x1cnz".*;
const n = f.feed(&fwd);
try std.testing.expectEqual(PrefixFilter.Action.next_session, n.action);
// Same reason `c` drops its tail: "z" was typed at the session we are
// leaving, and the only sessions left to put it in are the wrong ones.
try std.testing.expectEqualStrings("ab", n.forward);
var back = "\x1cp".*;
const p = f.feed(&back);
try std.testing.expectEqual(PrefixFilter.Action.prev_session, p.action);
try std.testing.expectEqualStrings("", p.forward);
}
test "interact: Ctrl-\\ w asks for the wall and ends the chunk" {
var f: PrefixFilter = .{};
var chunk = "ab\x1cwz".*;
const out = f.feed(&chunk);
try std.testing.expectEqual(PrefixFilter.Action.wall, out.action);
// The drop the user is most likely to notice, because unlike a switch
// they come BACK to this session: "z" is gone, and the reason is that
// it was typed before the wall took the terminal.
try std.testing.expectEqualStrings("ab", out.forward);
}
// The chord layer only NAMES an intent; what a driver does with it is the
// driver's. What is pinned here is the naming: the byte is parsed, the
// chunk ends, and the action that comes out is the one the key promises.
test "interact: Ctrl-\\ 1 is focus, x ends the session, w is the wall, n/p walk it" {
var f: PrefixFilter = .{};
var one = "\x1c1".*;
const a = f.feed(&one);
// focus carries the digit pressed; the wall does not act on it yet, but
// the chord still ends the chunk, so nothing is forwarded.
try std.testing.expectEqual(PrefixFilter.Action{ .focus = 1 }, a.action);
try std.testing.expectEqualStrings("", a.forward);
var g: PrefixFilter = .{};
var ex = "\x1cx".*;
const b = g.feed(&ex);
// `x` takes the pane off this wall and asks the daemon nothing.
try std.testing.expectEqual(PrefixFilter.Action.remove_pane, b.action);
try std.testing.expectEqualStrings("", b.forward);
// Shift-x is a DIFFERENT action, not the same one reached by a second
// spelling: `x` is the reversible key and `X` ends a session on its
// daemon, so a table that folded the two would make every removal a
// kill. Neither byte is forwarded — both end the chord.
var gx: PrefixFilter = .{};
var exx = "\x1cX".*;
const bx = gx.feed(&exx);
try std.testing.expectEqual(PrefixFilter.Action.end_focused, bx.action);
try std.testing.expectEqualStrings("", bx.forward);
// The three keys that must not have moved under `x`'s feet: `w` is
// still the wall and `n`/`p` are still the walk, so a user who learned
// the row learned it once.
var h: PrefixFilter = .{};
var w = "\x1cw".*;
try std.testing.expectEqual(PrefixFilter.Action.wall, h.feed(&w).action);
var i: PrefixFilter = .{};
var n = "\x1cn".*;
try std.testing.expectEqual(PrefixFilter.Action.next_session, i.feed(&n).action);
var j: PrefixFilter = .{};
var p = "\x1cp".*;
try std.testing.expectEqual(PrefixFilter.Action.prev_session, j.feed(&p).action);
}
fn devNull() !std.posix.fd_t {
return std.posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0);
}
/// The plain client's rect: one tile filling the screen. Offset 0 is a case
/// stated here, not one a fixture assumes — the tile at an offset is next
/// to it, and both paths are walked.
const full_vp: paint_mod.Viewport = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 };
/// Only the tests reach for an engine now: one authors the screens a client
/// would be sent, and one plays the terminal the client paints onto.
const Engine = @import("engine").Engine;
/// An engine and the grid it mirrors into. A test describes a screen in VT,
/// and what the code under test reads is the cells — through the daemon's
/// encoder and the replica's decoder, which is the only way a client grid is
/// ever filled. Re-mirrored on every feed, so the two never drift.
const AuthoredScreen = struct {
eng: *Engine,
grid: *Grid,
fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !AuthoredScreen {
const g = try Grid.init(alloc, cols, rows);
errdefer g.deinit();
const e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
return .{ .eng = e, .grid = g };
}
fn feed(self: *AuthoredScreen, bytes: []const u8) void {
self.eng.feed(bytes);
self.eng.mirrorInto(self.grid) catch unreachable;
}
fn deinit(self: *AuthoredScreen) void {
self.eng.deinit();
self.grid.deinit();
}
};
/// One CellRow of default-styled ASCII: the shape a delta row carries, for
/// a test that hands a Core a frame rather than a screen.
fn cellRow(alloc: std.mem.Allocator, text: []const u8) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
var w = try proto.CellRowWriter.begin(&out, alloc);
errdefer w.deinit();
for (text) |ch| try w.cell(.{}, .narrow, &[_]u8{ch});
w.finish();
return out.toOwnedSlice(alloc);
}
/// Author a screen straight into a grid somebody else owns — a Core's
/// replica, where a test wants the Core to hold content it never received.
fn authorScreen(alloc: std.mem.Allocator, g: *Grid, bytes: []const u8) !void {
const e = try Engine.init(alloc, .{ .cols = g.cols, .rows = g.rows });
defer e.deinit();
e.feed(bytes);
try e.mirrorInto(g);
}
test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
const alloc = std.testing.allocator;
const null_fd = try devNull();
defer std.posix.close(null_fd);
// A real engine, fed real VT bytes — the one part of the prediction
// contract no test inside predict.zig can reach, because that module
// has never heard of an engine.
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
// Content with the cursor parked ON a character and a DIFFERENT
// character in the cell after it. That difference is the whole test:
// with nothing pending the replica's cursor and the predicted one agree,
// and mid-burst they do not.
scr.feed("abcXY\x1b[1;4H");
try std.testing.expectEqual(@as(u16, 3), replica.cursor.x);
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
ov.noteSeq(1);
offerKeystroke(alloc, &ov, replica, "d", full_vp, null_fd);
offerKeystroke(alloc, &ov, replica, "e", full_vp, null_fd);
try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch);
// The second keystroke lands in the cell AFTER the first prediction,
// and takes that cell's content as its prev_ch.
try std.testing.expectEqual(@as(u16, 4), ov.pendingAt(1).cell.col);
try std.testing.expectEqual(@as(u8, 'Y'), ov.pendingAt(1).prev_ch);
// A frame that changed neither cell: the daemon has not seen the
// keystrokes, so both predictions must survive it. Read `prev_ch` from the
// wrong cell and the frame reads as a contradiction and flushes the burst.
try std.testing.expectEqual(
predict.Verdict.none,
reconcileOverlay(alloc, &ov, replica, 2, 0),
);
try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.contradicted);
// And when the daemon does answer, they confirm against the real grid.
scr.feed("\x1b[1;4Hde");
try std.testing.expectEqual(
predict.Verdict.confirmed,
reconcileOverlay(alloc, &ov, replica, 3, 0),
);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 2), ov.counters.confirmed);
}
test "prediction: a burst advances the predicted cursor one cell per keystroke" {
const alloc = std.testing.allocator;
const null_fd = try devNull();
defer std.posix.close(null_fd);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, full_vp, null_fd);
// The replica's own cursor has not moved — the daemon has answered
// nothing — so every one of these came from the overlay.
try std.testing.expectEqual(@as(u16, 0), replica.cursor.x);
try std.testing.expectEqual(@as(usize, 5), ov.pendingCount());
for ("hello", 0..) |ch, i| {
const p = ov.pendingAt(i);
try std.testing.expectEqual(@as(u16, @intCast(i)), p.cell.col);
try std.testing.expectEqual(ch, p.cell.ch);
try std.testing.expectEqual(@as(u8, ' '), p.prev_ch);
}
try std.testing.expectEqual(
predict.CursorPos{ .x = 5, .y = 0 },
ov.predictedCursor(.{ .x = 0, .y = 0 }),
);
}
test "prediction paints underlined, and parks the cursor past what it drew" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe();
defer std.posix.close(p[0]);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
scr.feed("\x1b[1;4H"); // cursor at column 3 (0-based)
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
offerKeystroke(alloc, &ov, replica, "z", full_vp, p[1]);
std.posix.close(p[1]);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
var rbuf: [4096]u8 = undefined;
while (true) {
const n = try std.posix.read(p[0], &rbuf);
if (n == 0) break;
try out.appendSlice(alloc, rbuf[0..n]);
}
// Drawn at the predicted cell, underlined so a speculation is visibly
// one, and with the SGR closed again so it cannot bleed into the rest.
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[1;4H\x1b[4mz\x1b[0m") != null);
// Cursor left one past it: the typist's next character goes there, and
// if it did not the shell's own cursor would appear to lag a column
// behind everything they typed.
try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[1;5H\x1b[?25h\x1b[?2026l"));
// Wrapped in one synchronized update, so no terminal ever shows the
// half-drawn state.
try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h"));
}
test "a keystroke's prediction lands in the typist's own tile" {
// Without both offsets a tile below or right of the wall's corner
// echoes its own typing onto a neighbour's session.
const alloc = std.testing.allocator;
const p = try std.posix.pipe();
defer std.posix.close(p[0]);
var scr = try AuthoredScreen.init(alloc, tile_cols, tile_rows);
defer scr.deinit();
const replica = scr.grid;
var ov = predict.Overlay.init(alloc, tile_cols, tile_rows);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
offerKeystroke(alloc, &ov, replica, "z", .{
.top = drag_row_off,
.left = drag_col_off,
.rows = tile_rows,
.cols = tile_cols,
}, p[1]);
std.posix.close(p[1]);
var rbuf: [4096]u8 = undefined;
const n = try std.posix.read(p[0], &rbuf);
const screen = try tileScreen(alloc);
defer screen.deinit();
screen.feed(rbuf[0..n]);
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
var want: [tile_screen_cols]u8 = undefined;
@memcpy(&want, tile_seed_row);
want[drag_col_off] = 'z';
var it = std.mem.splitScalar(u8, plain, '\n');
var y: u16 = 0;
while (it.next()) |line| : (y += 1) {
try std.testing.expectEqualStrings(if (y == drag_row_off) &want else tile_seed_row, line);
}
// The caret parks one past the speculated cell, or the typist's next
// character appears a column behind the one before it.
try std.testing.expectEqual(
Engine.CursorPos{ .x = drag_col_off + 1, .y = drag_row_off },
screen.cursorPos(),
);
}
test "prediction: nothing is drawn for a context that has not earned it" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe();
defer std.posix.close(p[0]);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned
offerKeystroke(alloc, &ov, replica, "z", full_vp, p[1]);
std.posix.close(p[1]);
// Queued, so it can be judged and earn the next one its visibility...
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
// ...and not one byte went to the terminal. The effect, not the counter:
// this is the assertion that a password prompt depends on.
var rbuf: [64]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
}
test "prediction: a chunk that is not one printable byte is never speculated about" {
const alloc = std.testing.allocator;
const null_fd = try devNull();
defer std.posix.close(null_fd);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = true, .echo = true });
// An arrow key: three bytes, and predicting its lead byte would paint an
// escape character on the screen.
offerKeystroke(alloc, &ov, replica, "\x1b[A", full_vp, null_fd);
// A multi-byte character, whose display width we do not know.
offerKeystroke(alloc, &ov, replica, "é", full_vp, null_fd);
// And a lone control byte, which goes down the single-byte path and is
// refused there.
offerKeystroke(alloc, &ov, replica, "\r", full_vp, null_fd);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
try std.testing.expectEqual(@as(u64, 3), ov.counters.suppressed);
// A paste: several printable bytes in one read. Its lead byte sails
// through the printability check, so the length guard is the only thing
// refusing it — and a paste is not one cell's worth of change.
offerKeystroke(alloc, &ov, replica, "abc", full_vp, null_fd);
try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
// Counted like every other refusal. The decision is the client's — the
// overlay never sees the chunk — but the counter is about decisions,
// not about which side of the interface made them.
try std.testing.expectEqual(@as(u64, 4), ov.counters.suppressed);
}
test "prediction: a repaint never reveals what was never shown" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe();
defer std.posix.close(p[0]);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
const null_fd = try devNull();
defer std.posix.close(null_fd);
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident
offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd);
offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd);
try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
// Every authoritative paint re-lays the overlay on top, because a delta's
// row content wipes anything drawn over it — a second chance to show a
// prediction, so it asks the keystroke path's question and gets its answer.
paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
std.posix.close(p[1]);
var rbuf: [64]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(p[0], &rbuf));
}
test "prediction: a promotion mid-burst counts the cell it makes visible" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe();
defer std.posix.close(p[0]);
const null_fd = try devNull();
defer std.posix.close(null_fd);
var scr = try AuthoredScreen.init(alloc, 80, 24);
defer scr.deinit();
const replica = scr.grid;
var ov = predict.Overlay.init(alloc, 80, 24);
defer ov.deinit();
ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned
// The keystrokes are stamped with the real clock, so the frames that
// confirm them land 100ms later: a remote path. At 0 the overlay would
// measure a 0ms round trip and hide the promotion this test is about.
const later = std.time.milliTimestamp() + 100;
// One confirm banked, one short of promotion.
offerKeystroke(alloc, &ov, replica, "a", full_vp, null_fd);
scr.feed("a");
try std.testing.expectEqual(
predict.Verdict.confirmed,
reconcileOverlay(alloc, &ov, replica, 1, later),
);
// Two more typed while still invisible, and the promoting confirmation
// lands while the second of them is outstanding.
offerKeystroke(alloc, &ov, replica, "b", full_vp, null_fd);
offerKeystroke(alloc, &ov, replica, "c", full_vp, null_fd);
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
scr.feed("b");
try std.testing.expectEqual(
predict.Verdict.confirmed,
reconcileOverlay(alloc, &ov, replica, 2, later),
);
try std.testing.expect(ov.visible());
try std.testing.expectEqual(@as(usize, 1), ov.pendingCount());
// Nothing has been drawn yet: it was queued invisible and no repaint
// has happened since the promotion.
try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
// The post-frame re-lay is where it reaches the screen — nobody typed
// anything to make that happen, so counting only at prediction time
// would lose it.
paintOverlay(alloc, &ov, replica.cursor, full_vp, p[1]);
std.posix.close(p[1]);
try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
var rbuf: [4096]u8 = undefined;
while (true) {
const n = try std.posix.read(p[0], &rbuf);
if (n == 0) break;
try out.appendSlice(alloc, rbuf[0..n]);
}
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[4mc\x1b[0m") != null);
}
test "the predict stats line is one greppable row of counters" {
var buf: [predict_stats_len]u8 = undefined;
const line = try formatPredictStats(&buf, .{
.made = 5,
.displayed = 4,
.confirmed = 3,
.contradicted = 2,
.expired = 1,
.abandoned = 7,
.suppressed = 6,
.local = 8,
});
// Pinned exactly: test/e2e.sh greps these key=value pairs, so a rename
// or a reorder is a broken suite rather than a cosmetic change. The
// units differ between them — see predict.Counters — which is exactly
// why every one of them is on the line rather than a chosen few.
try std.testing.expectEqualStrings(
"predict made=5 displayed=4 confirmed=3 contradicted=2" ++
" expired=1 abandoned=7 suppressed=6 local=8",
line,
);
}
/// Stands in a caller's buffer before a refusal, so "wrote nothing" is
/// distinguishable from "never writes anything". Not base64, not part of
/// any escape the builder emits.
const refusal_sentinel: u8 = 0xfe;
test "interact: a validated clipboard effect becomes an OSC 52 write" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
.target = 'c',
.base64 = "aGk=",
} });
// BEL rather than ESC-backslash: it is what most emitters in the wild
// use, and every terminal that accepts one accepts it.
try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items);
}
test "interact: a validated bell effect becomes a BEL" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try appendHostEffect(&out, alloc, .bell);
try std.testing.expectEqualStrings("\x07", out.items);
}
test "interact: xterm Pc targets retain their exact OSC 52 spelling" {
const alloc = std.testing.allocator;
for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| {
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
.target = target,
.base64 = "aGk=",
} });
const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 };
try std.testing.expectEqualSlices(u8, &want, out.items);
}
}
test "interact: terminal mode state turns bracketed paste on and off on the host" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
// The mouse level-set follows every mode sample, so the paste bytes are
// asserted as a prefix and the mouse half gets its own test below.
try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } });
try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004h"));
out.clearRetainingCapacity();
try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } });
try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2004l"));
}
test "interact: with no application asking, the client keeps the mouse for the wheel" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
// A format mode alone is not a claim on the mouse: nothing asked for an
// event, so the client's own capture set stays ours and 1005 goes back
// off. 1002 is inside that set — a drag cannot be escalated once it has
// begun, so motion reporting is on before the press that needs it.
try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_utf8 = true });
try std.testing.expectEqualStrings(
"\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++
"\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l",
out.items,
);
}
test "interact: an application that asked for the mouse gets exactly the modes it asked for" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
// vim's `set mouse=a` asks for exactly the set the client holds for
// itself, so these bytes match the no-application answer above. That
// identity is what the feature rests on, not a coincidence to route around.
try appendMouseModes(&out, alloc, .{
.bracketed_paste = false,
.mouse_normal = true,
.mouse_button = true,
.mouse_sgr = true,
});
try std.testing.expectEqualStrings(
"\x1b[?9l\x1b[?1000h\x1b[?1002h\x1b[?1003l" ++
"\x1b[?1005l\x1b[?1006h\x1b[?1015l\x1b[?1016l",
out.items,
);
// ...so the override has to be proven where the two sets part, or the
// leg above would still pass against a function that ignored its
// argument. 1003 (report motion with no button down) is a mode the
// client never wants and an application can still have.
out.clearRetainingCapacity();
try appendMouseModes(&out, alloc, .{
.bracketed_paste = false,
.mouse_normal = true,
.mouse_any = true,
.mouse_sgr = true,
});
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1003h") != null);
// An application on the legacy format gets it by SUBTRACTION: our own
// 1006 would spell every click in a shape it cannot parse, and 1002 would
// send drags it never asked for. The leg proving the override removes.
out.clearRetainingCapacity();
try appendMouseModes(&out, alloc, .{ .bracketed_paste = false, .mouse_normal = true });
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1006l") != null);
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?1002l") != null);
}
test "interact: a title becomes an OSC 0 write, and empty or control bytes are refused" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try appendTermTitle(&out, alloc, "vim");
try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items);
// Seeded, not merely emptied: `len == 0` on a buffer that started empty
// also passes for a builder that appends nothing ever.
out.clearRetainingCapacity();
try out.append(alloc, refusal_sentinel);
// A BEL inside the title would terminate the OSC early and paint the
// rest — here a shell command — on the user's screen as text.
try appendTermTitle(&out, alloc, "vim\x07rm -rf");
try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
// ESC is the other terminator half (ST), and DEL is the control byte
// that is not below 0x20 — both are refused by the same check.
out.clearRetainingCapacity();
try out.append(alloc, refusal_sentinel);
try appendTermTitle(&out, alloc, "vim\x1b]52;c;AAAA\x07");
try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
out.clearRetainingCapacity();
try out.append(alloc, refusal_sentinel);
try appendTermTitle(&out, alloc, "vim\x7f");
try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
// Empty is refused rather than written: `ESC]0;BEL` would CLEAR the
// host terminal's title, and no daemon of any version has a reason to
// ask for that. See sampleTermTitle for the daemon half of this policy.
out.clearRetainingCapacity();
try out.append(alloc, refusal_sentinel);
try appendTermTitle(&out, alloc, "");
try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
}
test "interact: the title cap is a cap, not an off-by-one" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
const at_cap = try alloc.alloc(u8, proto.term_title_max);
defer alloc.free(at_cap);
@memset(at_cap, 'x');
try appendTermTitle(&out, alloc, at_cap);
// "\x1b]0;" is four bytes and the BEL is one.
try std.testing.expectEqual(proto.term_title_max + 5, out.items.len);
const over = try alloc.alloc(u8, proto.term_title_max + 1);
defer alloc.free(over);
@memset(over, 'x');
out.clearRetainingCapacity();
try out.append(alloc, refusal_sentinel);
try appendTermTitle(&out, alloc, over);
try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
}
test "interact: the exit teardown unsets every mode mux turned on, and pops the title" {
// A multiplexer that leaves your terminal in a mode it enabled is worse
// than one that pastes badly, so the teardown string is pinned as a
// literal rather than assembled from the constants it writes.
try std.testing.expectEqualStrings(
"\x1b[?2004l" ++
"\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l" ++
"\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l" ++
"\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l",
terminal_teardown,
);
// Without its push, the pop is worthless and pops a stranger's title. The
// two live far apart — one startup write against a teardown on every way
// out — so they are pinned together and deleting either one fails.
try std.testing.expectEqualStrings(
"\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++
"\x1b[H\x1b[2J",
wall_setup,
);
// Every mode the setup turns on has an `l` for it in the teardown. The
// mouse half is the one that can drift, because the daemon can ask for
// modes this string never mentions.
inline for (proto.mouse_modes) |m| {
try std.testing.expect(std.mem.indexOf(
u8,
terminal_teardown,
comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}),
) != null);
}
}
test "interact: a borrowed terminal's claim is a session's, and every teardown undoes it" {
// A tile's focus claim is the mouse modes and nothing else — the SCREEN
// was taken once, by the wall — and the release heads every teardown. This
// pairing turns over once per focus move where the screen's turns over
// once per process, so an exit-path-only half leaks on the first move.
try std.testing.expectEqualStrings("\x1b[?1000h\x1b[?1002h\x1b[?1006h", session_claim);
try std.testing.expect(std.mem.startsWith(u8, terminal_teardown, session_release));
try std.testing.expect(std.mem.indexOf(u8, wall_teardown, session_release) != null);
// And it is exported, because the release is written by a thread that
// holds no Core: wallview's `setFocus`. A release that only a Core could
// write is a release that cannot be ordered against the next Core's
// claim.
try std.testing.expectEqualStrings(
"\x1b[?2004l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l",
session_release,
);
// The wall no longer claims the mouse itself: the focused tile's
// `claimTerminal` does. So `wall_setup` is the screen half only — no
// mouse modes — and the teardown still clears them all because it is
// built from the whole wire table.
try std.testing.expect(std.mem.indexOf(u8, wall_setup, client_mouse_setup) == null);
try std.testing.expect(std.mem.startsWith(u8, wall_setup, "\x1b[22;0t\x1b[?1049h"));
// ...and every mouse mode comes off on the way out. `wall_teardown`
// is `terminal_teardown`, which is built from the whole wire table, so
// this holds for a mode added to the capture set tomorrow as well.
inline for (client_mouse_capture) |dec| {
try std.testing.expect(std.mem.indexOf(
u8,
wall_teardown,
comptime std.fmt.comptimePrint("\x1b[?{d}l", .{dec}),
) != null);
}
// Every mode the tile's claim turns ON has an `l` for it in the
// release...
inline for (client_mouse_capture) |dec| {
try std.testing.expect(std.mem.indexOf(
u8,
session_claim,
comptime std.fmt.comptimePrint("\x1b[?{d}h", .{dec}),
) != null);
try std.testing.expect(std.mem.indexOf(
u8,
session_release,
comptime std.fmt.comptimePrint("\x1b[?{d}l", .{dec}),
) != null);
}
// ...and so does every mode the DAEMON can ask a focused tile to
// mirror, which is the half that drifts: a focused tile's application
// can turn on 1002 or 1003, modes the client's own capture set never
// names and nothing else would take back off.
inline for (proto.mouse_modes) |m| {
try std.testing.expect(std.mem.indexOf(
u8,
session_release,
comptime std.fmt.comptimePrint("\x1b[?{d}l", .{m.dec}),
) != null);
}
}
/// Everything on a NON-BLOCKING pipe, as one slice: these are emitted
/// one escape sequence per `write`, so a reader that took the first
/// for the whole answer would pin half a pair.
fn drainPipe(fd: std.posix.fd_t, buf: []u8) []const u8 {
var n: usize = 0;
while (std.posix.read(fd, buf[n..])) |got| {
if (got == 0) break;
n += got;
} else |_| {}
return buf[0..n];
}
test "interact: a promote takes the mouse, a demote gives it back, a demote twice writes nothing" {
const alloc = std.testing.allocator;
// Non-blocking, so a half of the pair that stopped writing FAILS here
// rather than parking this test on a read that will never return. A
// wedged test step prints nothing at all, and a mutation that hangs the
// suite is a mutation whose answer nobody gets.
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 80, .rows = 24 });
// A pipe is not a tty, and a Core with no terminal claims nothing —
// which is the case this test is NOT about. Set by hand rather than
// built on a pty fixture, because what is under test is four escape
// sequences and a flag.
core.is_tty = true;
defer core.deinit();
var buf: [512]u8 = undefined;
try std.testing.expect(core.claimTerminal());
try std.testing.expectEqual(Claim.session, core.claim);
// Two writes, drained as one: the claim itself and then the SESSION's
// modes level-set on top of it.
const claimed = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.startsWith(u8, claimed, session_claim));
// A mode the claim never names, so its presence can only be the level-set
// — the half a claim needs because `resyncSnapshot` carries no
// `term_modes`. 1003 not 1002: the claim writes 1002 itself.
try std.testing.expect(std.mem.indexOf(u8, claimed, "\x1b[?1003l") != null);
// A claim onto the tile already focused re-asserts the grid, not the
// modes: nothing is written a second time, so nothing has to come off
// twice either. `false` says so — the same answer a claim gets when
// the focus moved out from under it.
try std.testing.expect(!core.claimTerminal());
core.releaseTerminal(.write);
try std.testing.expectEqual(Claim.none, core.claim);
try std.testing.expectEqualStrings(session_release, drainPipe(p[0], &buf));
// A release with nothing claimed writes nothing. That is what makes
// `deinit` safe on a tile nobody ever claimed — and what stops a wall
// from unsetting a mode the SESSION never set, on a terminal the wall
// is about to hand back to the user.
core.releaseTerminal(.write);
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
// `.already_written` drops the claim and writes NOTHING, because the
// bytes were somebody else's to emit and they already did. A shared
// terminal's handover is ordered by that split; a second copy of the
// release from here is the race it exists to remove.
try std.testing.expect(core.claimTerminal());
_ = drainPipe(p[0], &buf);
core.releaseTerminal(.already_written);
try std.testing.expectEqual(Claim.none, core.claim);
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
/// Swallows everything: `forward` writes the keystrokes it did not
/// consume, and no test here is about them.
const NullTransport = struct {
fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {}
};
/// A transport that keeps the selection requests and swallows the rest —
/// for the tests whose subject is what went on the WIRE rather than what
/// went on the screen. `forward` also writes keystrokes, and those are
/// nobody's business here.
const SelectionTransport = struct {
n: usize = 0,
buf: [proto.selection_req_len]u8 = undefined,
fn writeFrame(self: *SelectionTransport, ty: proto.MsgType, payload: []const u8) !void {
if (ty != .selection_req or payload.len != self.buf.len) return;
self.n += 1;
@memcpy(&self.buf, payload);
}
};
/// A real pts pair — the OS answering about the OS, which is the only
/// thing `ttySize`'s own read of the terminal can be judged against. The
/// opening is `client_os.openPtyPair`'s; this name stays because the tests
/// below read as a story about a pts pair rather than about a platform row.
fn ptsPair() !client_os.PtyPair {
return client_os.openPtyPair();
}
fn setTtySize(master: std.posix.fd_t, cols: u16, rows: u16) !void {
return client_os.setWinSize(master, .{ .col = cols, .row = rows, .xpixel = 0, .ypixel = 0 });
}
test "interact: a terminal under the daemon's floor measures as unknown" {
// Not "under 2": under whatever the daemon will run a session at. A
// client that quotes a size `resolveSession` refuses attaches to a
// session that never moves again — so the floor has one owner, and
// this is what fails when a second copy of it drifts.
const pair = ptsPair() catch return error.SkipZigTest;
defer std.posix.close(pair.master);
defer std.posix.close(pair.slave);
try setTtySize(pair.master, proto.min_session_cols - 1, proto.min_session_rows);
try std.testing.expect(ttySize(pair.slave) == null);
try setTtySize(pair.master, proto.min_session_cols, proto.min_session_rows - 1);
try std.testing.expect(ttySize(pair.slave) == null);
// At the floor exactly it is a usable terminal, read back off the
// ioctl rather than echoed from the value just written.
try setTtySize(pair.master, proto.min_session_cols, proto.min_session_rows);
try std.testing.expectEqual(
proto.Size{ .cols = proto.min_session_cols, .rows = proto.min_session_rows },
ttySize(pair.slave).?,
);
}
/// The fixture's pane starts twelve columns into the screen, so every test
/// that drags through it exercises the offset. Standing, not opt-in:
/// `col_off` had eighteen uses in this file and not one inside a test body,
/// and that is how a pane that painted across the rail shipped.
const drag_col_off: u16 = 12;
/// And four rows down, for the same reason on the other axis: a tile with a
/// neighbour ABOVE it is the case where a paint or a hit-test that forgets
/// the row origin lands on somebody else's session.
const drag_row_off: u16 = 4;
/// CHA to a 1-based PANE column: the sequence is screen-absolute, so the
/// pane's origin is in it.
fn cha(comptime col: u16) []const u8 {
return std.fmt.comptimePrint("\x1b[{d}G", .{col + drag_col_off});
}
/// CUP to the pane's left edge on a 1-based PANE row, screen-absolute like
/// the CHA above.
fn cup(comptime row: u16) []const u8 {
return std.fmt.comptimePrint("\x1b[{d};{d}H", .{ row + drag_row_off, drag_col_off + 1 });
}
/// Forward one SGR mouse report at a 1-based PANE column and row. A test
/// says where the hand went in the pane; this says where that is on the
/// screen, which is the conversion `hitTest` has to undo.
fn mouse(core: *Core, tr: anytype, btn: u16, col: u16, row: u16, kind: u8) !void {
var buf: [32]u8 = undefined;
const rep = std.fmt.bufPrint(&buf, "\x1b[<{d};{d};{d}{c}", .{
btn,
col + drag_col_off,
row + drag_row_off,
kind,
}) catch unreachable;
_ = try core.forward(tr, rep);
}
/// A Core on a pipe, with a claim, ready to be dragged over.
fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 });
core.is_tty = true;
core.col_off = drag_col_off;
core.row_off = drag_row_off;
// A pane with neighbours around it, which is what an offset means: a
// whole-screen clear here would blank the pane across the rail.
core.owns_screen = false;
// A drag only happens on a terminal this Core took over — which is
// exactly the focused-tile case, and the reason a non-focused tile
// never reaches any of this.
_ = core.claimTerminal();
// Row 4 carries wide cells, kept OFF the rows the column assertions use:
// a wide glyph shifts every column right of it, so folding one into row 1
// means re-deriving fifteen hand-checked numbers.
try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three\r\nw\u{6f22}\u{5b57}x");
return core;
}
test "interact: a drag at the focus tile inverts what it crossed, and a click does not" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// Press on row 1, column 2. Nothing is painted: a press that turns out
// to be a click must not flicker an inversion on its way past.
try mouse(&core, &tr, 0, 3, 2, 'M');
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
// Drag to column 6 of the same row.
try mouse(&core, &tr, 32, 7, 2, 'M');
const painted = drainPipe(p[0], &buf);
// Columns 2..6 of grid row 1, addressed by column and inverted: the
// span opens at column 3 (1-based) and the tail resumes at column 8.
// The head is plain, the span opens at column 3 (1-based) with a CHA and
// an inversion, and the row closes with a full reset so the inversion
// cannot run on into whatever the terminal draws next. There is no tail
// to resume: columns 2 to 6 are the last of `row-one`, and a row stops at
// its last non-blank cell. The test below covers a selection that leaves
// content after it.
try std.testing.expect(std.mem.indexOf(
u8,
painted,
comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-one\x1b[0m",
) != null);
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
// The neighbouring rows are not touched at all: only the row whose
// span changed is redrawn, so an untouched row keeps whatever it
// already had on screen — which is why it must not appear here, and
// why the inversion count above is the whole story.
try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") == null);
try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") == null);
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));
// The release does not move the highlight, so it does not repaint one.
try mouse(&core, &tr, 0, 7, 2, 'm');
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
// A press elsewhere puts the selection away — and the repaint that
// says so carries no inversion at all.
try mouse(&core, &tr, 0, 1, 4, 'M');
const cleared = drainPipe(p[0], &buf);
// The row that CARRIED the highlight is the row that gets redrawn —
// dropping a selection changes the same rows that making it did — and
// it comes back with no inversion on it.
try std.testing.expect(std.mem.indexOf(u8, cleared, "row-one") != null);
try std.testing.expect(std.mem.indexOf(u8, cleared, "\x1b[7m") == null);
// ...and that press is a click, which a focused tile answers with
// nothing: there is one session on this screen to select.
try mouse(&core, &tr, 0, 1, 4, 'm');
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
test "interact: a selection that ends mid-row resumes the tail plain, at its own column" {
// The three-piece shape: plain head, inverted span, plain tail. The tail
// is what a substring search cannot see going missing — the cells are
// painted either way, and only the CHA says the cursor went back to the
// column they belong in rather than running on from the span.
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// Row 1 is `row-one`; the drag covers columns 2 to 4, leaving `n` and `e`
// after it.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 5, 2, 'M');
const painted = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(
u8,
painted,
comptime "\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7mw-o\x1b[0m" ++ cha(6) ++ "ne\x1b[0m",
) != null);
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m"));
}
test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") != null);
// vim's `set mouse=a` arrives mid-drag. The reports are the
// application's from here, and the selection standing on its screen is
// not mux's to keep.
core.semantic.terminal_modes = .{
.bracketed_paste = false,
.mouse_normal = true,
.mouse_button = true,
.mouse_sgr = true,
};
try mouse(&core, &tr, 32, 9, 2, 'M');
const after = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, after, "row-one") != null);
try std.testing.expect(std.mem.indexOf(u8, after, "\x1b[7m") == null);
// A further drag under the application selects nothing at all.
try mouse(&core, &tr, 32, 11, 2, 'M');
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
/// A transport that keeps the keystroke frames — what `forward` sends the
/// SESSION — for the tests whose subject is the bytes an application reads.
const InputTransport = struct {
buf: [256]u8 = undefined,
len: usize = 0,
fn writeFrame(self: *InputTransport, ty: proto.MsgType, payload: []const u8) !void {
if (ty != .input) return;
@memcpy(self.buf[self.len..][0..payload.len], payload);
self.len += payload.len;
}
fn take(self: *InputTransport) []const u8 {
defer self.len = 0;
return self.buf[0..self.len];
}
};
/// The fixture with its application holding the mouse, which is the case
/// where the report goes to the session instead of into a selection.
fn appMouseFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
var core = try dragFixture(alloc, out_fd);
core.semantic.terminal_modes = .{
.bracketed_paste = false,
.mouse_normal = true,
.mouse_button = true,
.mouse_sgr = true,
};
return core;
}
test "interact: the application's report is in the pane's coordinates, not the screen's" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try appMouseFixture(alloc, p[1]);
defer core.deinit();
var tr: InputTransport = .{};
// The terminal reports where the hand went on ITS screen. The
// application has a 20x6 grid that starts four rows down and twelve
// columns in, so a report it can act on names the same cell in the
// grid's own numbering — press, drag, release and wheel alike.
try mouse(&core, &tr, 0, 3, 2, 'M');
try std.testing.expectEqualStrings("\x1b[<0;3;2M", tr.take());
try mouse(&core, &tr, 32, 4, 2, 'M');
try std.testing.expectEqualStrings("\x1b[<32;4;2M", tr.take());
try mouse(&core, &tr, 0, 4, 2, 'm');
try std.testing.expectEqualStrings("\x1b[<0;4;2m", tr.take());
try mouse(&core, &tr, 64, 3, 2, 'M');
try std.testing.expectEqualStrings("\x1b[<64;3;2M", tr.take());
// Keys sharing the read with a report keep their order around it.
_ = try core.forward(&tr, "a\x1b[<0;15;6Mb");
try std.testing.expectEqualStrings("a\x1b[<0;3;2Mb", tr.take());
}
test "interact: a report past the pane's edge clamps to the edge, as a terminal clamps at its own" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try appMouseFixture(alloc, p[1]);
defer core.deinit();
var tr: InputTransport = .{};
// A drag that leaves the pane keeps reporting, at the edge it left
// by: a real terminal reports a drag past its window the same way, and
// dropping the report instead would leave the application holding a
// button the hand has let go of. Screen (1,1) is above and left of the
// pane; pane column 30 and row 9 are past its 20x6.
_ = try core.forward(&tr, "\x1b[<32;1;1M");
try std.testing.expectEqualStrings("\x1b[<32;1;1M", tr.take());
try mouse(&core, &tr, 32, 30, 9, 'M');
try std.testing.expectEqualStrings("\x1b[<32;20;6M", tr.take());
}
test "interact: a pixel report is the terminal's to place and passes untouched" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try appMouseFixture(alloc, p[1]);
defer core.deinit();
core.semantic.terminal_modes.mouse_sgr_pixels = true;
var tr: InputTransport = .{};
// Pixel coordinates are not cells, so the pane's cell origin cannot
// come off them; an application that asked for pixels reads the
// screen's. A wrong translation here would be worse than none.
_ = try core.forward(&tr, "\x1b[<0;150;60M");
try std.testing.expectEqualStrings("\x1b[<0;150;60M", tr.take());
}
test "interact: a drag while scrolled back selects nothing" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// Viewing history: `renderScrollback` paints decoded history rows this
// Core does not hold, so nothing on this screen is a row it can name.
// Refused outright rather than answered against the live view the user
// is not looking at.
core.scroll_rows = 4;
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
try std.testing.expect(core.drag.range() == null);
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
test "interact: a press while scrolled back does not repaint over the history" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// A selection made at the LIVE view and left standing. The test above
// starts with none, so `was` and `now` are both null there and the
// scroll gate it names is not the thing keeping it green.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
try mouse(&core, &tr, 0, 7, 2, 'm');
try std.testing.expect(core.drag.range() != null);
_ = drainPipe(p[0], &buf);
// Wheel up, then a click on the history page. `hitTest` refuses while
// scrolled, so the press clears the held range — and the rows it used
// to cover must NOT be redrawn from the live replica onto a screen
// `renderScrollback` composed.
core.scroll_rows = 4;
try mouse(&core, &tr, 0, 5, 2, 'M');
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
test "interact: a reattach drops the highlight, because the rows have been renamed" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 3, 'M');
try std.testing.expect(core.drag.range() != null);
_ = drainPipe(p[0], &buf);
// Absolute rows count from the oldest row the daemon still retains,
// and a resync renames that space outright.
core.reattached();
try std.testing.expect(core.drag.range() == null);
try core.repaint();
try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") == null);
}
/// The tile's own columns, and the screen it is one tile of.
const tile_cols: u16 = 20;
const tile_rows: u16 = 6;
const tile_screen_cols: u16 = 40;
const tile_screen_rows: u16 = 12;
/// One untouched screen row. Anything the tile's paint changes outside its
/// own columns is a cell of somebody else's session.
const tile_seed_row = "L" ** (drag_col_off - 1) ++ "|" ++ "R" ** tile_cols ++ "|" ++
"X" ** (tile_screen_cols - drag_col_off - tile_cols - 1);
/// The seed on every row, ready to be painted onto.
fn tileScreen(alloc: std.mem.Allocator) !*Engine {
const screen = try Engine.init(alloc, .{ .cols = tile_screen_cols, .rows = tile_screen_rows });
// What the client sets at attach; it is also why an overrun HIDES,
// piling at the screen edge instead of wrapping into view.
screen.feed("\x1b[?7l");
var r: u16 = 0;
while (r < tile_screen_rows) : (r += 1) {
var nb: [16]u8 = undefined;
screen.feed(std.fmt.bufPrint(&nb, "\x1b[{d};1H", .{r + 1}) catch unreachable);
screen.feed(tile_seed_row);
}
return screen;
}
/// A Core whose rect is that tile, with one distinct digit per grid row so
/// a row landing in the wrong band is legible in the failure.
fn tileCore(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core {
var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = tile_cols, .rows = tile_rows });
core.is_tty = true;
core.row_off = drag_row_off;
core.col_off = drag_col_off;
core.owns_screen = false;
_ = core.claimTerminal();
var body: std.ArrayList(u8) = .empty;
defer body.deinit(alloc);
var y: u16 = 0;
while (y < tile_rows) : (y += 1) {
var row: [tile_cols]u8 = undefined;
@memset(&row, '0' + @as(u8, @intCast(y)));
try body.appendSlice(alloc, &row);
if (y + 1 < tile_rows) try body.appendSlice(alloc, "\r\n");
}
try authorScreen(alloc, core.rep.grid, body.items);
return core;
}
test "interact: a Core paints inside its rect and onto no neighbour" {
// Both offsets at once, judged on the grid the paint lands on. The
// byte search this replaces asked whether the tile's own address was
// emitted, which is true of a paint that ALSO wrote a row above the
// tile or a cell across the rail — and both have shipped.
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try tileCore(alloc, p[1]);
defer core.deinit();
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
try core.repaint();
const screen = try tileScreen(alloc);
defer screen.deinit();
screen.feed(drainPipe(p[0], &buf));
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
var it = std.mem.splitScalar(u8, plain, '\n');
var y: u16 = 0;
while (it.next()) |line| : (y += 1) {
if (y < drag_row_off or y >= drag_row_off + tile_rows) {
try std.testing.expectEqualStrings(tile_seed_row, line);
continue;
}
var want: [tile_screen_cols]u8 = undefined;
@memcpy(&want, tile_seed_row);
@memset(want[drag_col_off..][0..tile_cols], '0' + @as(u8, @intCast(y - drag_row_off)));
try std.testing.expectEqualStrings(&want, line);
}
// The caret rides both offsets, or a shell's prompt and its cursor sit
// in different tiles. The grid's cursor is parked past the last row's
// twenty columns, which DECAWM off holds at the tile's last column.
try std.testing.expectEqual(
Engine.CursorPos{ .x = drag_col_off + tile_cols - 1, .y = drag_row_off + tile_rows - 1 },
screen.cursorPos(),
);
}
test "interact: a Core's banner parks in its own tile's corner" {
// A status marker belongs in the FOCUSED TILE's top-right corner. On
// the screen's, it sits on whatever tile owns row 1.
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try tileCore(alloc, p[1]);
defer core.deinit();
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
const label = "[hi]";
core.banner(label);
const screen = try tileScreen(alloc);
defer screen.deinit();
// A cursor with somewhere to be: the banner saves and restores it so a
// shell's caret does not visibly jump to the corner.
screen.feed("\x1b[9;3H");
screen.feed(drainPipe(p[0], &buf));
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
// `bannerText` right-aligns on a 1-BASED column, so the label stops one
// cell short of the tile's right edge — the plain client's banner does
// the same, and `renderScrollback at row 0 is byte-identical to the
// plain client` is what would object to changing it.
var want: [tile_screen_cols]u8 = undefined;
@memcpy(&want, tile_seed_row);
@memcpy(want[drag_col_off + tile_cols - label.len - 1 ..][0..label.len], label);
var it = std.mem.splitScalar(u8, plain, '\n');
var y: u16 = 0;
while (it.next()) |line| : (y += 1) {
try std.testing.expectEqualStrings(if (y == drag_row_off) &want else tile_seed_row, line);
}
try std.testing.expectEqual(Engine.CursorPos{ .x = 2, .y = 8 }, screen.cursorPos());
}
test "interact: the anchor is an absolute row, and the paint converts it back" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// A session with history behind it, which is every used session. With
// none retained a grid row and an absolute row are the same integer, and
// neither half of the conversion is under test.
core.rep.history_rows = 500;
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
const r = core.drag.range().?;
// Terminal row 1 is grid row 1 is the 501st retained row — the space
// `protocol.SelectionReq` speaks, and the one a scroll cannot rename.
try std.testing.expectEqual(@as(u32, 501), r.from.row);
try std.testing.expectEqual(@as(u32, 501), r.to.row);
// ...and the paint puts the inversion back on grid row 1, which is the
// other half of the same arithmetic: a painter that took the absolute
// row literally would invert a row 500 below the one pointed at, or
// none at all.
const painted = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, painted, comptime cup(2) ++ "\x1b[20X\x1b[0mro" ++ cha(3) ++ "\x1b[0m\x1b[7m") != null);
}
test "interact: a drag repaint puts the predictions back on top" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// Cooked with echo is the mode the overlay is confident enough to draw
// in, and therefore the only state where there is a prediction on
// screen to lose.
core.overlay.setGrid(core.rep.grid.cols, core.rep.grid.rows);
core.overlay.setMode(.{ .icanon = true, .echo = true });
// One byte at a time: a chunk wider than a cell is a paste, and
// prediction refuses those.
_ = try core.forward(&tr, "z");
_ = try core.forward(&tr, "z");
const typed = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, typed, "\x1b[4m") != null);
// The drag repaints from the replica, which has never heard of a
// prediction. These are still outstanding — the daemon has not
// answered — so they have to go back on top: `paintFull` is the
// ROLLBACK painter, and a drag rolls nothing back.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
const painted = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[4m") != null);
}
test "interact: a delta under a held selection stays a delta" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [16384]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// A finished selection, still held — the state a hand leaves behind
// between the release and the next click, and the one a session goes
// on producing output underneath.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 2, 'M');
try mouse(&core, &tr, 0, 7, 2, 'm');
_ = drainPipe(p[0], &buf);
try std.testing.expect(core.drag.range() != null);
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 1,
.history_rows = 0,
.cursor_x = 0,
.cursor_y = 0,
.row_count = 1,
});
const delta_row = try cellRow(alloc, "row-one");
defer alloc.free(delta_row);
try proto.appendDeltaRow(&payload, alloc, 1, delta_row);
_ = try core.frame(.delta, payload.items);
const painted = drainPipe(p[0], &buf);
// Still a delta: the screen-clear that opens a full repaint is absent.
// A held selection used to force the full-repaint arm on every frame,
// which cost the whole screen for a row the daemon had already sent.
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null);
// ...and the row the daemon just rewrote comes back inverted anyway,
// which is the reason the full repaint was there in the first place.
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
}
test "interact: a drag repaints the rows it changed, not the screen" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [16384]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
try mouse(&core, &tr, 0, 3, 2, 'M');
_ = drainPipe(p[0], &buf);
// One cell along the same row. The anchor has not moved, so exactly
// one row's span changed — and a drag reports on every cell the
// pointer crosses, which is what makes the difference between a row
// and a screen worth having.
try mouse(&core, &tr, 32, 4, 2, 'M');
const painted = drainPipe(p[0], &buf);
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[7m") != null);
// No screen clear: that is what opens a full repaint.
try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null);
// One row addressed and cleared, and it is the row under the pointer.
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));
try std.testing.expect(std.mem.indexOf(u8, painted, comptime cup(2) ++ "\x1b[20X") != null);
}
test "interact: a drag edge inside a wide cell repaints the row unshifted" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [16384]u8 = undefined;
_ = drainPipe(p[0], &buf); // the claim
// Row 4 is `w漢字x`: column 2 is the spacer tail of 漢 and column 3 the
// first half of 字, so BOTH edges of this drag fall inside a wide cell.
try mouse(&core, &tr, 0, 3, 5, 'M');
try mouse(&core, &tr, 32, 4, 5, 'M');
const painted = drainPipe(p[0], &buf);
// Snapped outwards to whole glyphs: the inversion opens at column 2
// (0-based 1, where 漢 starts), not at the column the pointer stopped in.
try std.testing.expect(std.mem.indexOf(u8, painted, comptime cha(2) ++ "\x1b[0m\x1b[7m") != null);
// The oracle: replay what the client actually WROTE through a fresh engine
// and read the row back. Asserting escape bytes cannot see this class of
// bug — a row emitting 漢 twice still contains every expected substring.
var screen = try Engine.init(alloc, .{ .cols = 20, .rows = 6 });
defer screen.deinit();
screen.feed(painted);
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
try std.testing.expect(std.mem.indexOf(u8, plain, "w\u{6f22}\u{5b57}x") != null);
}
test "interact: only the left button drags" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// Middle button (1) and right (2). One is the terminal's own paste and
// the other its menu; stealing either would be a surprise with no
// answer, and the button word arrives with the motion bit set on a
// drag — 33 is a middle-button drag, not a press of button 33.
try mouse(&core, &tr, 1, 3, 2, 'M');
try mouse(&core, &tr, 33, 7, 2, 'M');
try std.testing.expect(core.drag.range() == null);
try mouse(&core, &tr, 2, 3, 3, 'M');
try mouse(&core, &tr, 34, 7, 3, 'M');
try std.testing.expect(core.drag.range() == null);
try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf));
}
test "interact: a press left of the pane names no line, and the clamp is the pane's" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// Screen column 1 is a cell of the neighbour across the rail: the column
// axis refuses left of the tile as the row axis refuses above it. The row
// here is inside the tile's band, so only the column can refuse.
_ = try core.forward(&tr, comptime std.fmt.comptimePrint("\x1b[<0;1;{d}M", .{drag_row_off + 2}));
try std.testing.expect(core.drag.range() == null);
// The pane's own first column does name a line, and names it zero.
try mouse(&core, &tr, 0, 1, 2, 'M');
try mouse(&core, &tr, 32, 4, 2, 'M');
try std.testing.expectEqual(@as(u16, 0), core.drag.range().?.from.col);
// A pane narrower than the grid clamps to what it SHOWS: the grid's
// remaining columns are behind the rail, where no hand can reach.
core.size.cols = 8;
try mouse(&core, &tr, 32, 15, 2, 'M');
try std.testing.expectEqual(@as(u16, 7), core.drag.range().?.to.col);
}
test "interact: a row past the grid names no line, and a column past it clamps" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
// A terminal taller and wider than the daemon's grid, which latest-wins
// leaves routinely.
var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 40, .rows = 12 });
core.is_tty = true;
core.col_off = drag_col_off;
core.row_off = drag_row_off;
core.owns_screen = false;
_ = core.claimTerminal();
defer core.deinit();
var tr: NullTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// The grid is smaller than the Core's clip size: 20x6 against 40x12.
try core.rep.grid.resize(20, 6);
try authorScreen(alloc, core.rep.grid, "row-zero\r\nrow-one\r\nrow-two");
// Row 8 is inside the terminal and past the grid: `renderClipped`
// never painted a session line there.
try mouse(&core, &tr, 0, 3, 9, 'M');
try std.testing.expect(core.drag.range() == null);
// Column 34 is past the grid's right edge. It clamps to the last
// column instead of refusing — the right edge is where a hand
// overshoots — and an out-of-range column is what makes the daemon
// answer `.invalid` when the selection is asked for.
try mouse(&core, &tr, 0, 35, 2, 'M');
try mouse(&core, &tr, 32, 3, 2, 'M');
const r = core.drag.range().?;
try std.testing.expectEqual(@as(u16, 19), r.to.col);
try std.testing.expectEqual(@as(u16, 2), r.from.col);
}
test "interact: a release at the focus tile asks the daemon for what it highlighted" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: SelectionTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// History behind the session, which is every session that has been
// used: with none retained a grid row and an absolute row are the same
// integer and the request could be built out of either.
core.rep.history_rows = 500;
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 32, 7, 3, 'M');
// Nothing is asked for while the button is still down: the far end of
// the drag is still moving, and a request per cell crossed is a round
// trip per cell crossed.
try std.testing.expectEqual(@as(usize, 0), tr.n);
try mouse(&core, &tr, 0, 7, 3, 'm');
try std.testing.expectEqual(@as(usize, 1), tr.n);
const req = try proto.decodeSelectionReq(&tr.buf);
try std.testing.expectEqual(@as(u32, 501), req.anchor.row);
try std.testing.expectEqual(@as(u16, 2), req.anchor.col);
try std.testing.expectEqual(@as(u32, 502), req.active.row);
try std.testing.expectEqual(@as(u16, 6), req.active.col);
try std.testing.expectEqual(@as(?u32, req.id), core.semantic.pending_selection_id);
// The watermark comes from the replica the coordinates were resolved
// against, not from whatever the reply says.
try std.testing.expectEqual(@as(u32, 500), core.sel_watermark);
// A click asks for nothing. It highlights nothing either, so there is
// no text under it to want — and a round trip per click is a round
// trip per time the user puts a selection away.
try mouse(&core, &tr, 0, 3, 2, 'M');
try mouse(&core, &tr, 0, 3, 2, 'm');
try std.testing.expectEqual(@as(usize, 1), tr.n);
}
/// Laid out by hand, not encoded: a layout change must fail a test,
/// not agree with itself.
fn replyBytes(
buf: []u8,
id: u32,
status: proto.SelectionStatus,
history_rows: u32,
text: []const u8,
) []const u8 {
std.mem.writeInt(u32, buf[0..4], id, .little);
buf[4] = @intFromEnum(status);
std.mem.writeInt(u32, buf[5..9], history_rows, .little);
@memset(buf[9..proto.selection_reply_prefix_len], 0);
@memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text);
return buf[0 .. proto.selection_reply_prefix_len + text.len];
}
/// Drag over the fixture and let go, leaving one request in flight. Returns
/// the selection that is now held, which is what a reply is judged against.
fn askedFor(core: *Core, tr: *SelectionTransport) !select.Range {
try mouse(core, tr, 0, 3, 2, 'M');
try mouse(core, tr, 32, 7, 3, 'M');
try mouse(core, tr, 0, 7, 3, 'm');
return core.drag.range().?;
}
test "interact: a reply is the text that was asked for, or it is nothing" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: SelectionTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
core.rep.history_rows = 500;
var rbuf: [64]u8 = undefined;
var held = try askedFor(&core, &tr);
const id = core.semantic.pending_selection_id.?;
// The reply this request asked for.
switch (core.selectionCopy(replyBytes(&rbuf, id, .ok, 500, "row-one"), held)) {
.text => |text| try std.testing.expectEqualStrings("row-one", text),
else => return error.ExpectedText,
}
// A watermark LOWER than the one sampled at the ask: the daemon evicted
// a page in between, so absolute row zero is a different line and the
// text is for rows nobody pointed at. `.ok`, valid UTF-8, and wrong.
held = try askedFor(&core, &tr);
const evicted = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 499, "wrong-rows");
try std.testing.expectEqual(Copy.none, core.selectionCopy(evicted, held));
// A HIGHER one is ordinary output, which appends below and renames
// nothing. Refusing it would refuse every copy from a live session.
held = try askedFor(&core, &tr);
const grew = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 900, "row-one");
switch (core.selectionCopy(grew, held)) {
.text => |text| try std.testing.expectEqualStrings("row-one", text),
else => return error.ExpectedText,
}
// A reply that outlived its own highlight. Whatever cleared it — a
// relayout, a forget, a focus move, a resync — the rows it names are not on
// anybody's screen now, and a copy from it is a copy the user did not
// ask for and cannot see.
_ = try askedFor(&core, &tr);
const orphan = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
try std.testing.expectEqual(Copy.none, core.selectionCopy(orphan, null));
// ...and a reply that arrived over a DIFFERENT selection is the same
// thing said the other way: the highlight moved on before the answer
// came back.
held = try askedFor(&core, &tr);
var other = held;
other.to.row += 1;
const stale = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "row-one");
try std.testing.expectEqual(Copy.none, core.selectionCopy(stale, other));
// Somebody else's id — a reply to a request this drag replaced.
held = try askedFor(&core, &tr);
const wrong_id = replyBytes(&rbuf, core.semantic.pending_selection_id.? +% 1, .ok, 500, "row-one");
try std.testing.expectEqual(Copy.none, core.selectionCopy(wrong_id, held));
// An empty selection is nothing to put on a clipboard. OSC 52 with an
// empty payload is the CLEAR form, so proxying it would wipe whatever
// the human last copied (`client_core.validClipboard`).
held = try askedFor(&core, &tr);
const empty = replyBytes(&rbuf, core.semantic.pending_selection_id.?, .ok, 500, "");
try std.testing.expectEqual(Copy.none, core.selectionCopy(empty, held));
// The daemon's own refusals. Neither is a sentence the user can act on.
for ([_]proto.SelectionStatus{ .invalid, .unavailable }) |status| {
held = try askedFor(&core, &tr);
const refused = replyBytes(&rbuf, core.semantic.pending_selection_id.?, status, 500, "");
try std.testing.expectEqual(Copy.none, core.selectionCopy(refused, held));
}
}
test "interact: a demote drops the highlight, and the reply it was waiting for" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: SelectionTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
_ = try askedFor(&core, &tr);
var rbuf: [64]u8 = undefined;
const id = core.semantic.pending_selection_id.?;
// The focus moved on. This Core's screen belongs to somebody else now —
// the wall's stripes, or another tile — so the inversion it was
// holding is over rows nobody can see, and the answer still in flight
// is for a highlight that has stopped existing.
core.releaseTerminal(.already_written);
try std.testing.expect(core.drag.range() == null);
const late = replyBytes(&rbuf, id, .ok, 500, "row-one");
try std.testing.expectEqual(Copy.none, core.selectionCopy(late, core.drag.range()));
}
test "interact: a selection too big for OSC 52 is refused out loud, never trimmed" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var core = try dragFixture(alloc, p[1]);
defer core.deinit();
var tr: SelectionTransport = .{};
var buf: [8192]u8 = undefined;
_ = drainPipe(p[0], &buf);
// Base64 is 4 bytes per 3, so `protocol.clipboard_base64_max` is reached
// by three quarters of it in text — far under the 1 MiB the daemon will
// send, which is the gap this exists to fall into.
const fits = proto.clipboard_base64_max / 4 * 3;
const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + fits + 1);
defer alloc.free(rbuf);
const big = try alloc.alloc(u8, fits + 1);
defer alloc.free(big);
@memset(big, 'x');
var held = try askedFor(&core, &tr);
const at_cap = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big[0..fits]);
switch (core.selectionCopy(at_cap, held)) {
.text => |text| try std.testing.expectEqual(fits, text.len),
else => return error.ExpectedText,
}
// One byte more, and `validClipboard` would refuse the encoding — with
// `appendHostEffect` writing nothing and saying nothing, which is the
// silence this exists to break.
held = try askedFor(&core, &tr);
const over = replyBytes(rbuf, core.semantic.pending_selection_id.?, .ok, 0, big);
try std.testing.expectEqual(Copy.too_large, core.selectionCopy(over, held));
// The daemon's own cap, for a selection past `selection_text_max`. It
// arrives with no text at all, and it means the same thing to the user.
held = try askedFor(&core, &tr);
const daemons = replyBytes(rbuf, core.semantic.pending_selection_id.?, .too_large, 0, "");
try std.testing.expectEqual(Copy.too_large, core.selectionCopy(daemons, held));
}
test "interact: the copy leaves as OSC 52, through the one writer of it" {
const alloc = std.testing.allocator;
const p = try std.posix.pipe2(.{ .NONBLOCK = true });
defer std.posix.close(p[0]);
defer std.posix.close(p[1]);
var buf: [8192]u8 = undefined;
try writeSelectionCopy(alloc, p[1], "hi");
// `c` is the clipboard, which is what tmux's `set-clipboard external`
// sets and what a paste reads back.
try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", drainPipe(p[0], &buf));
}
test "interact: only what the driver has an answer of its own for comes back" {
const alloc = std.testing.allocator;
// No terminal at either end: every paint and every side channel is
// refused, so what this exercises is the ROUTING and nothing else.
var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
defer core.deinit();
// The set, named. Adding to it is asking every driver to learn a new
// meaning, so it is pinned rather than described. `selection_reply` is
// the one the Core has already half-answered — see `Routed.not_mine`.
const drivers_own = [_]proto.MsgType{
.exit_status,
.taken_over,
.sessions_reply,
.end_reply,
.agent_open,
.agent_data,
.agent_close,
.selection_reply,
};
for (drivers_own) |t| {
try std.testing.expectEqual(Routed.not_mine, try core.frame(t, ""));
}
// The offer is the client's own frame travelling the other way, so
// receiving one means nothing here.
try std.testing.expectEqual(Routed.skip, try core.frame(.agent_offer, ""));
const forwarding = [_]proto.MsgType{
.forward_hello,
.forward_open,
.forward_data,
.forward_credit,
.forward_half_close,
.forward_reset,
.forward_ready,
.forward_open_result,
};
for (forwarding) |t| {
try std.testing.expectEqual(Routed.skip, try core.frame(t, ""));
}
// ...and nothing else is, over every type the enum names.
inline for (@typeInfo(proto.MsgType).@"enum".fields) |f| {
const t: proto.MsgType = @enumFromInt(f.value);
var driver_owns = false;
for (drivers_own) |l| {
if (l == t) driver_owns = true;
}
if (!driver_owns) {
// An error is an answer too — it is certainly not the Core
// declining to route — so a type that fails on an empty payload
// counts as one the Core owns.
const r = core.frame(t, "") catch Routed.handled;
try std.testing.expect(r != .not_mine);
}
}
}
test "interact: a frame the Core answers itself never reaches the driver" {
const alloc = std.testing.allocator;
var core = try Core.initSized(alloc, -1, -1, .{ .cols = 80, .rows = 24 });
defer core.deinit();
// A title is looked at, acted on (refused here — no terminal claim) and
// done with. The driver's loop carries on without a branch for it.
try std.testing.expectEqual(Routed.handled, try core.frame(.term_title, "hello"));
// A snapshot too short to read left the replica untouched, so there is
// nothing to paint and nothing to record: the driver's `continue`.
try std.testing.expectEqual(Routed.skip, try core.frame(.snapshot, ""));
try std.testing.expect(!core.rep.state_since_attach);
// A page of history for a view that is already live would be painted
// over a screen it no longer describes.
try std.testing.expectEqual(Routed.skip, try core.frame(.scrollback_chunk, "\x00\x00\x00\x00\x01\x00"));
// A delta the replica could not compose is a resync — and STATE to its
// driver anyway, because `apply` marks the attach landed before it can
// refuse. A shell dying in the same read as its first delta must not lose it.
try std.testing.expectEqual(Routed.resync, try core.frame(.delta, ""));
try std.testing.expect(core.rep.state_since_attach);
}
test "interact: a snapshot that blanked the grid before failing is not .skip" {
// The other snapshot failure. `.skip` would leave the tile painting from
// a grid this frame cleared, over a last_seq it also adopted; the error
// goes out to the driver, which ends the tile.
const alloc = std.testing.allocator;
var core = try Core.initSized(alloc, -1, -1, .{ .cols = 8, .rows = 2 });
defer core.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
var pbuf: [proto.snapshot_prefix_len]u8 = undefined;
proto.writeSnapshotPrefix(&pbuf, .{
.seq = 5,
.history_rows = 0,
.cols = 8,
.rows = 2,
.epoch = 1,
});
try payload.appendSlice(alloc, &pbuf);
var cbuf: [proto.snapshot_cursor_len]u8 = undefined;
proto.writeSnapshotCursor(&cbuf, 0, 0);
try payload.appendSlice(alloc, &cbuf);
// A prefix good enough to resize and clear on, then a body that is not a
// CellRow at all.
try payload.appendSlice(alloc, "\x1b[1mVT");
try std.testing.expectError(error.SnapshotAborted, core.frame(.snapshot, payload.items));
}
test "interact: an unvalidated clipboard effect writes nothing" {
const alloc = std.testing.allocator;
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
// Exactly the value wasm_core.zig default-initialises its borrowed
// clipboard slot to, and exactly what client_core.validClipboard
// refuses. Written verbatim it is `ESC]52;<NUL>;BEL` on a real tty.
try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
.target = 0,
.base64 = &.{},
} });
try std.testing.expectEqual(@as(usize, 0), out.items.len);
// The refusal is the whole value, not just its target: a legal target
// carrying bytes outside the base64 alphabet is the injection this
// check exists for.
try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
.target = 'c',
.base64 = "aGk=\x1b]0;pwned\x07",
} });
try std.testing.expectEqual(@as(usize, 0), out.items.len);
}
test "interact: a clipboard effect at the cap retains its exact framing" {
const alloc = std.testing.allocator;
const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max);
defer alloc.free(at_cap);
@memset(at_cap, 'A');
// The boundary itself is ACCEPTED — stated because `>` and `>=` are one
// keystroke apart and the wrong one silently truncates the largest copy
// the daemon is willing to send.
{
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
.target = 'c',
.base64 = at_cap,
} });
// "\x1b]52;c;" ++ payload ++ BEL
try std.testing.expectEqual(at_cap.len + 8, out.items.len);
}
}
fn appendNothing(
_: *std.ArrayList(u8),
_: std.mem.Allocator,
_: void,
) std.mem.Allocator.Error!void {}
test "interact: side channels write nothing before terminal ownership" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var write_open = true;
defer if (write_open) std.posix.close(pipe[1]);
try writeSideChannel(
std.testing.allocator,
pipe[1],
false,
client_core.Effect,
.{ .bell = {} },
appendHostEffect,
);
std.posix.close(pipe[1]);
write_open = false;
var byte: [1]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}
test "interact: an empty side-channel rendering writes nothing" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var write_open = true;
defer if (write_open) std.posix.close(pipe[1]);
try writeSideChannel(
std.testing.allocator,
pipe[1],
true,
void,
{},
appendNothing,
);
std.posix.close(pipe[1]);
write_open = false;
var byte: [1]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}
test "interact: an allocation failure discards a partially built side channel" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
// The OSC introducer gets the first allocation. Growing for the payload
// then fails both its resize and allocation fallback, after real escape
// bytes exist in writeSideChannel's private buffer.
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{
.fail_index = 1,
.resize_fail_index = 0,
});
var payload: [128]u8 = undefined;
@memset(&payload, 'A');
const result = writeSideChannel(
failing.allocator(),
pipe[1],
true,
client_core.Effect,
.{ .clipboard_set = .{
.target = 'c',
.base64 = &payload,
} },
appendHostEffect,
);
std.posix.close(pipe[1]);
try std.testing.expectError(error.OutOfMemory, result);
try std.testing.expectEqual(@as(usize, 1), failing.allocations);
try std.testing.expect(failing.has_induced_failure);
var byte: [1]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &byte));
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}