a73x

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