src/client/selection.zig
Ref: Size: 15.7 KiB History
//! The drag behind text selection, as pure state: press, motion, release, and
//! the span of columns highlighted on one line of one session.
//!
//! No tty, no transport, no engine, no allocation beyond its own struct — a
//! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of
//! those means, which is what lets two drivers in different layers share one
//! meaning. Resolving a terminal row to a session line stays with the layout.
//!
//! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag
//! held while the session scrolls must keep naming the lines it started over,
//! and a terminal row renames itself the moment output moves the window.
const std = @import("std");
/// One place in one session, as a driver has resolved a report.
pub const Hit = struct {
tile: usize,
/// Absolute row: counted from the oldest row the daemon still retains.
row: u32,
/// Grid column, zero-based. Terminal column is the same number —
/// every painter emits from column 1 with no x-offset.
col: u16,
};
/// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER
/// is, not what is under it. The two part company when the session scrolls, and
/// telling a click from a drag is the pointer's question.
pub const Cell = struct { row: u16, col: u16 };
/// Inclusive columns, the same convention `engine.extractSelection` and
/// ghostty's `Selection` use at the other end of the wire.
pub const Span = struct { from: u16, to: u16 };
/// A completed selection, normalized: `from` is at or before `to` in
/// reading order, whichever way the hand moved.
pub const Range = struct {
from: Hit,
to: Hit,
/// On `Range` rather than `Drag` because two get compared: a drag that moved
/// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the
/// clamp is not decoration — a grid narrower than its terminal has columns a
/// pointer can reach with no cell behind them.
pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span {
if (self.from.tile != tile) return null;
if (row < self.from.row or row > self.to.row) return null;
const from = if (row == self.from.row) self.from.col else 0;
const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1);
// A selection whose whole width fell off the grid's right edge
// highlights nothing on this row, rather than one clamped cell at
// the edge that nobody pointed at.
if (from > to) return null;
return .{ .from = from, .to = to };
}
};
/// What a button coming up meant.
pub const Release = union(enum) {
/// Nothing was down, or the press landed on nothing selectable.
nothing,
/// Press and release in one cell. Not a selection — tmux's
/// `MouseDown1Pane`, which a driver answers by moving its own
/// selection to the thing clicked.
click: Hit,
/// A drag that ended. The highlight STANDS after this: it is what a
/// copy is taken from, and what the next press or `clear` drops.
selection: Range,
};
/// One button's worth of drag. Four states, because a press is not yet a
/// selection and a release is not the end of one: `.down` may still turn out to
/// be a click, and `.held` is a finished selection still on screen.
pub const Drag = struct {
const Phase = enum { idle, down, dragging, held };
phase: Phase = .idle,
/// The cell the press was on, which is what a later motion is
/// compared against to tell a drag from a tremor.
at: Cell = .{ .row = 0, .col = 0 },
anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 },
active: Hit = .{ .tile = 0, .row = 0, .col = 0 },
/// Whatever was held is dropped however the press turns out: a new
/// press is a new selection, and a press on nothing (a label bar) is
/// the user putting the old one away.
pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void {
const h = hit orelse {
self.* = .{};
return;
};
self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h };
}
/// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling
/// inside one cell still points at one line. Once it IS a drag it stays one.
/// Confined to its starting tile, or it would ask the wrong session.
pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void {
switch (self.phase) {
.idle, .held => return,
.down => {
if (cell.row == self.at.row and cell.col == self.at.col) return;
self.phase = .dragging;
},
.dragging => {},
}
const h = hit orelse return;
if (h.tile != self.anchor.tile) return;
self.active = h;
}
/// The button came up. See `Release` for what the three answers mean.
pub fn release(self: *Drag) Release {
switch (self.phase) {
.idle, .held => return .nothing,
.down => {
const hit = self.anchor;
self.* = .{};
return .{ .click = hit };
},
.dragging => {
self.phase = .held;
return .{ .selection = self.rangeLocked() };
},
}
}
/// Coordinates stopped meaning what they meant.
pub fn clear(self: *Drag) void {
self.* = .{};
}
/// A press that has not moved yet counts; `range`, by contrast,
/// answers only about what is on screen.
pub fn on(self: *const Drag) ?usize {
return if (self.phase == .idle) null else self.anchor.tile;
}
/// Whether the pointer button remains down and motion should be captured.
pub fn buttonHeld(self: *const Drag) bool {
return self.phase == .down or self.phase == .dragging;
}
/// The selection as an ordered pair, or null while there is none.
pub fn range(self: *const Drag) ?Range {
return switch (self.phase) {
.idle, .down => null,
.dragging, .held => self.rangeLocked(),
};
}
fn rangeLocked(self: *const Drag) Range {
const a = self.anchor;
const b = self.active;
const forward = b.row > a.row or (b.row == a.row and b.col >= a.col);
return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a };
}
/// `Range.span` for the live selection, or null while there is none.
pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span {
const r = self.range() orelse return null;
return r.span(tile, row, cols);
}
};
test "select: a press alone is a click, and highlights nothing on its way" {
var d: Drag = .{};
const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 };
d.press(.{ .row = 12, .col = 7 }, hit);
// Nothing is highlighted while the button is merely down: a press that
// turns out to be a click must never flicker an inversion on its way.
try std.testing.expect(d.span(1, 40, 80) == null);
try std.testing.expect(d.range() == null);
const r = d.release();
try std.testing.expectEqual(@as(usize, 1), r.click.tile);
try std.testing.expectEqual(@as(u32, 40), r.click.row);
try std.testing.expectEqual(@as(u16, 7), r.click.col);
// ...and the click leaves nothing behind to paint.
try std.testing.expect(d.range() == null);
}
test "select: absolute history rows do not narrow the pointer cell" {
var d: Drag = .{};
const origin: Hit = .{ .tile = 7, .row = 70_000, .col = 3 };
d.press(.{ .row = 2, .col = 3 }, origin);
try std.testing.expect(d.buttonHeld());
d.motion(.{ .row = 3, .col = 3 }, .{ .tile = 7, .row = 70_001, .col = 3 });
const release = d.release();
try std.testing.expectEqual(@as(u32, 70_001), release.selection.to.row);
try std.testing.expect(!d.buttonHeld());
try std.testing.expectEqual(@as(u16, 3), (d.range() orelse unreachable).from.col);
}
test "select: a drag names its tile from the press, before it is a selection" {
var d: Drag = .{};
try std.testing.expect(d.on() == null);
d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 });
// Pressed, not yet dragged: nothing to paint, and still a tile whose
// coordinates a caller may have to drop.
try std.testing.expect(d.range() == null);
try std.testing.expectEqual(@as(usize, 3), d.on().?);
d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 });
try std.testing.expectEqual(@as(usize, 3), d.on().?);
_ = d.release();
// Still held after the button came up, which is what a highlight is.
try std.testing.expectEqual(@as(usize, 3), d.on().?);
d.clear();
try std.testing.expect(d.on() == null);
}
test "select: a press on nothing selectable is nothing at all" {
var d: Drag = .{};
d.press(.{ .row = 0, .col = 3 }, null);
d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 });
try std.testing.expect(d.range() == null);
try std.testing.expect(d.release() == .nothing);
}
test "select: either drag direction yields the same ordered pair" {
const top: Hit = .{ .tile = 0, .row = 100, .col = 4 };
const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 };
var down: Drag = .{};
down.press(.{ .row = 2, .col = 4 }, top);
down.motion(.{ .row = 5, .col = 12 }, bot);
const a = down.release().selection;
var up: Drag = .{};
up.press(.{ .row = 5, .col = 12 }, bot);
up.motion(.{ .row = 2, .col = 4 }, top);
const b = up.release().selection;
try std.testing.expectEqual(a.from, b.from);
try std.testing.expectEqual(a.to, b.to);
try std.testing.expectEqual(@as(u32, 100), a.from.row);
try std.testing.expectEqual(@as(u16, 4), a.from.col);
try std.testing.expectEqual(@as(u32, 103), a.to.row);
try std.testing.expectEqual(@as(u16, 12), a.to.col);
}
test "select: a one-row drag reads left to right whichever way the hand moved" {
var d: Drag = .{};
d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 });
d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 });
const s = d.span(0, 50, 80).?;
try std.testing.expectEqual(@as(u16, 2), s.from);
try std.testing.expectEqual(@as(u16, 9), s.to);
// One row, so neither neighbour is in it.
try std.testing.expect(d.span(0, 49, 80) == null);
try std.testing.expect(d.span(0, 51, 80) == null);
}
test "select: the ends are clipped at the anchors, and the middle is the full width" {
var d: Drag = .{};
d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 });
d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 });
const first = d.span(2, 7, 80).?;
try std.testing.expectEqual(@as(u16, 30), first.from);
try std.testing.expectEqual(@as(u16, 79), first.to);
const middle = d.span(2, 8, 80).?;
try std.testing.expectEqual(@as(u16, 0), middle.from);
try std.testing.expectEqual(@as(u16, 79), middle.to);
const last = d.span(2, 10, 80).?;
try std.testing.expectEqual(@as(u16, 0), last.from);
try std.testing.expectEqual(@as(u16, 6), last.to);
// Outside the range on both sides.
try std.testing.expect(d.span(2, 6, 80) == null);
try std.testing.expect(d.span(2, 11, 80) == null);
}
test "select: the highlight is one tile's, and the drag cannot leave it" {
var d: Drag = .{};
d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 });
// A drag onto the neighbouring stripe: it IS a drag, and the active
// end stays on the last line of the tile it started in.
d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 });
const s = d.span(0, 5, 80).?;
try std.testing.expectEqual(@as(u16, 1), s.from);
try std.testing.expectEqual(@as(u16, 1), s.to);
// Neither the row it strayed onto nor the tile it strayed into.
try std.testing.expect(d.span(1, 200, 80) == null);
try std.testing.expect(d.span(0, 200, 80) == null);
// The same rows, asked for as somebody else's tile, are not the answer.
try std.testing.expect(d.span(1, 5, 80) == null);
}
test "select: a pointer that never leaves the press cell has not dragged" {
var d: Drag = .{};
d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
// `?1002h` reports on a cell change, but a terminal repeating the cell
// must not turn a click into an empty selection.
d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 });
try std.testing.expect(d.range() == null);
try std.testing.expect(d.release() == .click);
}
test "select: a drag that comes back to where it started is still a drag" {
var d: Drag = .{};
const home: Hit = .{ .tile = 0, .row = 6, .col = 20 };
d.press(.{ .row = 6, .col = 20 }, home);
d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 });
d.motion(.{ .row = 6, .col = 20 }, home);
try std.testing.expect(d.release() == .selection);
// One cell selected, and it is still on screen after the button is up.
const s = d.span(0, 6, 80).?;
try std.testing.expectEqual(@as(u16, 20), s.from);
try std.testing.expectEqual(@as(u16, 20), s.to);
}
test "select: the highlight outlives the release, and dies on clear" {
var d: Drag = .{};
d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
_ = d.release();
try std.testing.expect(d.span(0, 3, 80) != null);
// A motion after the button is up is somebody else's pointer moving
// over a selection that is finished.
d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 });
try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row);
try std.testing.expect(d.release() == .nothing);
d.clear();
try std.testing.expect(d.range() == null);
try std.testing.expect(d.span(0, 3, 80) == null);
}
test "select: a new press drops the selection the last one left" {
var d: Drag = .{};
d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 });
d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 });
_ = d.release();
d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 });
try std.testing.expect(d.span(0, 3, 80) == null);
try std.testing.expect(d.range() == null);
// ...and a press on nothing drops it just as thoroughly.
d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 });
try std.testing.expect(d.range() != null);
d.press(.{ .row = 0, .col = 0 }, null);
try std.testing.expect(d.range() == null);
}
test "select: a motion with no button down selects nothing" {
var d: Drag = .{};
d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 });
try std.testing.expect(d.range() == null);
try std.testing.expect(d.release() == .nothing);
}
test "select: a span past the grid's right edge is clipped, not painted at the edge" {
// The tty is wider than the grid — latest-wins leaves that shape
// routinely — so a pointer can reach columns with no cell behind them.
var d: Drag = .{};
d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 });
d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 });
// The first row's anchor is off the grid entirely: nothing to invert,
// and NOT one cell at column 39.
try std.testing.expect(d.span(0, 2, 40) == null);
// The last row runs from the left edge to the grid's own right edge.
const last = d.span(0, 3, 40).?;
try std.testing.expectEqual(@as(u16, 0), last.from);
try std.testing.expectEqual(@as(u16, 39), last.to);
}
// 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());
}