/// SelectionGesture manages gesture-based terminal text selection for one
/// pointer stream: press, drag, release, autoscroll, and pressure/deep-press
/// selection.
///
/// This type owns only the state required to interpret a gesture. It does not
/// modify the terminal selection directly, except for scrolling the viewport
/// during `autoscrollTick`. The caller feeds platform events into this type and
/// applies the returned `Selection` to the active screen when appropriate.
///
/// A typical single-click drag flow looks like this:
///
/// ```zig
/// const selection = try gesture.press(terminal, .{ ... });
/// try terminal.screens.active.select(selection);
/// if (gesture.drag(terminal, .{ ... })) |selection| {
///     try terminal.screens.active.select(selection);
/// }
/// gesture.release(terminal, .{ ... });
/// ```
///
/// Double- and triple-click gestures use the same event flow. Repeated presses
/// inside `Press.repeat_interval` and within `Press.max_distance` increment the
/// internal click count up to three. `Press.behaviors` maps single-, double-,
/// and triple-clicks to behavior. By default, a single press returns null to
/// clear any existing selection, a double-click returns a word selection, and a
/// triple-click returns a line selection. Drags use the behavior selected by the
/// corresponding press. A new press that is too late, too far away, or on
/// another active screen starts a new single-click gesture.
///
/// # Resetting and lifetime
///
/// `release` ends the active drag/autoscroll phase but intentionally preserves
/// enough state for a subsequent press to become a double- or triple-click.
/// Call `reset` when the gesture is cancelled rather than released normally, or
/// when another subsystem takes ownership of pointer input. Examples include
/// enabling mouse reporting for an application, losing pointer/button state,
/// destroying the surface, switching to a mode that must not continue text
/// selection, or otherwise abandoning the current click sequence. Call `deinit`
/// once before discarding the gesture object so any tracked click pin is
/// released.
///
/// # Terminal and screen changes
///
/// The initial press pin is tracked in the active screen's `PageList`, so normal
/// terminal output and viewport scrolling can move rows without making the
/// gesture immediately stale. Selection results are computed against the current
/// terminal contents at the time of each call. For example, a double-click drag
/// selects word boundaries from the screen as it exists during `drag`, not from a
/// snapshot captured at `press`.
///
/// The tracked pin is tied to both a `ScreenSet.Key` and that screen's
/// generation. If the active screen changes, or a screen is removed/recycled,
/// `validatedLeftClickPin` returns null and drag-style operations stop producing
/// selections. `autoscrollTick` treats this as cancellation and calls `reset` so
/// callers can stop their timers. This avoids exposing pins from inactive or
/// freed screens, but it does not make a historical snapshot of terminal data.
///
/// # Concurrency
///
/// SelectionGesture is not concurrency safe. It has mutable gesture state and
/// mutates/tracks pins inside the terminal page list without taking locks. The
/// caller must serialize all calls that touch the same gesture and terminal,
/// typically by holding the same terminal/renderer mutex used for other screen
/// mutations. Do not call `press`, `drag`, `release`, `reset`, `deinit`, or
/// `autoscrollTick` concurrently with each other or with unrelated terminal
/// mutations unless the caller provides that synchronization.
const SelectionGesture = @This();

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const lib = @import("lib.zig");
const PageList = @import("PageList.zig");
const Pin = PageList.Pin;
const Screen = @import("Screen.zig");
const ScreenSet = @import("ScreenSet.zig");
const Selection = @import("Selection.zig");
const Terminal = @import("Terminal.zig");
const point = @import("point.zig");

const freestanding_wasm = builtin.target.cpu.arch == .wasm32 and
    builtin.target.os.tag == .freestanding;

/// The tracked pin of the initial left click along with the screen
/// that the pin is part of.
left_click_pin: ?*Pin,
left_click_screen: ScreenSet.Key,
left_click_screen_generation: usize,

/// The count of clicks to count double and triple clicks and so on.
/// The left click time was the last time the left click was done, if the
/// caller could provide one. If this is null then we only support single clicks.
left_click_count: u3,
left_click_time: ?std.Io.Timestamp,

/// The selection behavior chosen for the active left-click gesture.
left_click_behavior: Behavior,

/// The starting xpos/ypos of the left click. Note that if scrolling occurs,
/// these will point to different cells, but the xpos/ypos will stay
/// stable during scrolling relative to the surface.
left_click_xpos: f64,
left_click_ypos: f64,

/// True once the active left-click gesture has moved away from the initially
/// pressed cell. This is reset on every press that starts or continues a
/// multi-click sequence, and is left available for callers to inspect while
/// handling the corresponding release.
left_click_dragged: bool,

/// The current autoscroll state for the active left-click drag gesture.
left_drag_autoscroll: Autoscroll,

/// The direction that selection dragging should autoscroll the viewport.
/// This is derived from the most recent drag position relative to the
/// surface bounds and reset whenever there is no active drag gesture.
///
/// When autoscroll is non-none, the caller should setup a timer
/// to periodically call autoscrollTick. The timer interval is up to the
/// caller but reasonable defaults are approximately every 15 milliseconds.
///
/// This is used to implement selection above/below the viewport that
/// wants to drag the viewport.
pub const Autoscroll = lib.Enum(lib.target, &.{
    "none",
    "up",
    "down",
});

/// The selection behavior for a click and subsequent drag.
pub const Behavior = lib.Enum(lib.target, &.{
    // Cell-granular drag selection. Press returns null to clear selection.
    "cell",

    // Word selection on press and word-granular drag selection.
    "word",

    // Line selection on press and line-granular drag selection.
    "line",

    // Semantic command output selection on press and drag.
    "output",
});

/// Standard terminal selection behavior for single-, double-, and triple-clicks.
///
/// A single click uses cell behavior, which returns null on press so callers can
/// clear any existing selection and then drag by cell. A double-click selects and
/// drags by word. A triple-click selects and drags by line.
pub const default_behaviors: [3]Behavior = .{ .cell, .word, .line };

/// Distance from the top or bottom surface edge, in pixels, where dragging
/// should request autoscroll. This preserves the historical 1px buffer used
/// so fullscreen-edge drags can still trigger autoscroll.
const autoscroll_buffer: f64 = 1;

pub const init: SelectionGesture = .{
    .left_click_pin = null,
    .left_click_count = 0,
    .left_click_time = null,
    .left_click_behavior = .cell,
    .left_click_screen = .primary,
    .left_click_screen_generation = 0,
    .left_click_xpos = 0,
    .left_click_ypos = 0,
    .left_click_dragged = false,
    .left_drag_autoscroll = .none,
};

pub fn deinit(self: *SelectionGesture, t: *Terminal) void {
    // Grab our pagelist that is associated with the pin. If it doesn't
    // exist anymore then our tracked pin is already free.
    const pin = self.left_click_pin orelse return;
    if (t.screens.generation(self.left_click_screen) != self.left_click_screen_generation) return;
    const screen = t.screens.get(self.left_click_screen) orelse return;
    screen.pages.untrackPin(pin);
}

/// Reset any active gesture state and untrack the tracked click pin.
///
/// Use this for cancellation/abandonment, not for the ordinary left-button
/// release path. `release` deliberately keeps the last press time/count so a
/// following press can become a double- or triple-click; `reset` clears that
/// sequence and makes the next press a fresh single click.
///
/// Examples of reset-worthy events are: mouse reporting taking over, pointer
/// capture being lost, a surface/window being torn down, or another interaction
/// mode deciding that text selection must stop immediately. If the active screen
/// was already removed or recycled, this safely drops the stale reference without
/// trying to untrack a pin from the wrong screen generation.
pub fn reset(self: *SelectionGesture, t: *Terminal) void {
    self.left_click_count = 0;
    self.left_click_time = null;
    self.left_click_behavior = .cell;
    self.left_click_dragged = false;
    self.left_drag_autoscroll = .none;
    self.untrackPin(t);
}

/// Return the tracked left-click pin only if it still belongs to the current
/// active screen instance.
///
/// This validates both the screen key and generation so a pin from a removed,
/// recycled, or inactive screen is never exposed to callers. A null result means
/// callers should treat the in-progress gesture as temporarily or permanently
/// unable to produce a selection. For a normal drag this usually means "do
/// nothing for this event"; for autoscroll it is treated as cancellation because
/// a timer should not continue firing for a gesture that no longer has a valid
/// anchor.
pub fn validatedLeftClickPin(
    self: *const SelectionGesture,
    screens: *const ScreenSet,
) ?*Pin {
    const pin = self.left_click_pin orelse return null;
    if (self.left_click_screen != screens.active_key) return null;
    if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null;
    _ = screens.get(self.left_click_screen) orelse return null;
    return pin;
}

pub const Press = struct {
    /// The time when the press event occurred. Prefer a monotonic timer;
    /// backwards timestamps reset the repeat sequence.
    /// This can be null if you're on a system that doesn't support
    /// time for some reason. In that case, we only support single clicks.
    time: ?std.Io.Timestamp,

    /// The cell where the click was.
    ///
    /// `press` stores a tracked copy of this pin. The caller does not need to
    /// keep `p.pin` alive after the call returns, but the pin must belong to the
    /// terminal's active screen when passed in.
    pin: Pin,

    /// The x/y value of the click relative to the surface with (0,0) being
    /// top-left. This is used for distance detection for multi-clicks so
    /// double/triple clicks too far away from each other will reset the click
    /// count as well more accurate drag behaviors.
    xpos: f64,
    ypos: f64,

    /// Maximum distance a click can be from the original click to register
    /// as a repeat. If uncertain, set this to cell width.
    max_distance: f64,

    /// The maximum interval in nanoseconds that a press is considered
    /// a repeat e.g. to record double/triple clicks.
    repeat_interval: u64,

    /// The codepoints that delimit words for double-click selection.
    word_boundary_codepoints: []const u21,

    /// Selection behaviors for single-, double-, and triple-clicks.
    behaviors: *const [3]Behavior = &default_behaviors,
};

/// Record a press event and return the standard selection for this click.
///
/// If this press continues the existing click sequence, the click count is
/// incremented up to three and the original anchor pin is kept. Otherwise, the
/// previous gesture state is cleared and this press becomes the new anchor.
/// The returned selection is untracked and represents the standard terminal
/// click behavior for the resulting click count. The caller is responsible for
/// applying it to the screen, usually with `Screen.select`, and for arranging
/// any copy-on-select behavior.
///
/// Examples:
///
/// * first press: `left_click_count == 1`, defaults to cell behavior;
/// * second nearby press within the repeat interval: `left_click_count == 2`,
///   defaults to word behavior;
/// * third nearby press within the repeat interval: `left_click_count == 3`,
///   defaults to line behavior;
/// * press after the interval, too far away, or after a screen generation
///   change: starts over at `left_click_count == 1` and returns null.
pub fn press(
    self: *SelectionGesture,
    t: *Terminal,
    p: Press,
) Allocator.Error!?Selection {
    if (self.left_click_count > 0) {
        if (self.pressRepeat(t, p)) {
            // Successful repeat.
            return self.pressSelection(t.screens.active, p);
        } else |err| switch (err) {
            error.PressRequiresReset => {},
        }
    }

    // Initial click or the repeat failed for some reason such as
    // the subsequent click being too far away.
    try self.pressInitial(t, p);
    return self.pressSelection(t.screens.active, p);
}

pub const Drag = struct {
    /// The cell where the current drag position is. This is used
    /// synchronously to calculate the selection and is not tracked.
    pin: Pin,

    /// The x/y value of the drag relative to the surface with (0,0) being
    /// top-left.
    xpos: f64,
    ypos: f64,

    /// True if the current drag should produce a rectangular selection.
    rectangle: bool,

    /// The codepoints that delimit words for double-click drag selection.
    word_boundary_codepoints: []const u21,

    /// Geometry required for selection threshold and autoscroll calculations.
    geometry: Geometry,

    /// Display geometry needed to translate surface-relative pointer positions
    /// into selection behavior.
    pub const Geometry = struct {
        /// The number of columns in the rendered terminal grid.
        columns: u32,

        /// The width of one terminal cell in surface pixels.
        cell_width: u32,

        /// The left padding before the terminal grid begins, in surface pixels.
        padding_left: u32,

        /// The height of the rendered terminal surface in surface pixels.
        screen_height: u32,
    };
};

/// Record a drag event and return the current untracked drag selection.
///
/// The returned selection is untracked and represents the best selection for the
/// terminal contents at the time of this call. The caller is responsible for
/// applying it to the screen, usually with `Screen.select`, and for arranging any
/// copy-on-select behavior. A null result means either there is no active
/// selection gesture, the original press is no longer valid for the active
/// screen, or the drag has not crossed the threshold required to select a cell.
///
/// This method also updates `left_click_dragged` and `left_drag_autoscroll`.
/// If `left_drag_autoscroll` becomes `.up` or `.down`, the caller should start or
/// keep a timer that calls `autoscrollTick` while the button remains pressed. If
/// it becomes `.none`, the caller should stop that timer.
///
/// Normal terminal output and viewport movement between drag events are allowed:
/// the tracked press pin follows the page list, and the drag pin is used only
/// synchronously. Content-sensitive selections such as word and line selection
/// are recalculated from the current active screen every time.
pub fn drag(
    self: *SelectionGesture,
    t: *Terminal,
    d: Drag,
) ?Selection {
    // If we aren't currently clicked then we don't do any dragging
    // behavior.
    if (self.left_click_count == 0) {
        assert(self.left_drag_autoscroll == .none);
        return null;
    }

    // Get our click pin. We get a validated pin because if our
    // screen changed out from under us then we aren't actually
    // clicking anymore.
    const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null;
    if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true;

    // Determine if we should autoscroll. If our drag position is above
    // the top, we go up. If its below the bottom we go down. Easy.
    const max_y: f64 = @floatFromInt(d.geometry.screen_height);
    self.left_drag_autoscroll = if (d.ypos <= autoscroll_buffer)
        .up
    else if (d.ypos > max_y - autoscroll_buffer)
        .down
    else
        .none;

    const selection = switch (self.left_click_behavior) {
        .cell => dragSelection(
            click_pin.*,
            d.pin,
            pixelFromFloat(self.left_click_xpos),
            pixelFromFloat(d.xpos),
            d.rectangle,
            d.geometry,
        ),

        .word => dragSelectionWord(
            t.screens.active,
            click_pin.*,
            d.pin,
            d.word_boundary_codepoints,
        ),

        .line => dragSelectionLine(
            t.screens.active,
            click_pin.*,
            d.pin,
        ),

        .output => dragSelectionOutput(
            t.screens.active,
            click_pin.*,
            d.pin,
        ),
    };

    // Same-cell cell selections can still become real selections when the drag
    // crosses the within-cell threshold. Treat those as drags so callers don't
    // also process click-only actions such as opening links.
    if (self.left_click_behavior == .cell and selection != null) {
        self.left_click_dragged = true;
    }

    return selection;
}

pub const AutoscrollTick = struct {
    /// The viewport cell where the current drag position is. This is resolved
    /// after the viewport is scrolled so the selection tracks the newly visible
    /// row under the pointer.
    viewport: point.Coordinate,

    /// The x/y value of the drag relative to the surface with (0,0) being
    /// top-left.
    xpos: f64,
    ypos: f64,

    /// True if the current drag should produce a rectangular selection.
    rectangle: bool,

    /// The codepoints that delimit words for double-click drag selection.
    word_boundary_codepoints: []const u21,

    /// Geometry required for selection threshold and autoscroll calculations.
    geometry: Drag.Geometry,
};

/// Record a selection autoscroll tick for the active left-click drag gesture.
///
/// This scrolls the viewport in the active autoscroll direction and then
/// continues the drag at the provided viewport position. The viewport position
/// is resolved to a pin after scrolling so the drag applies to the row now under
/// the pointer.
///
/// This always scrolls the viewport by exactly one row in the current
/// autoscroll direction. If you want to scroll by more, increase your
/// tick rate.
///
/// If the original press pin no longer belongs to the active screen, this calls
/// `reset` and returns null. That is a signal for the caller to stop its
/// autoscroll timer and leave any existing terminal selection alone unless some
/// other event says otherwise.
pub fn autoscrollTick(
    self: *SelectionGesture,
    t: *Terminal,
    tick: AutoscrollTick,
) ?Selection {
    if (self.left_click_count == 0) {
        assert(self.left_drag_autoscroll == .none);
        return null;
    }

    const delta: isize = switch (self.left_drag_autoscroll) {
        .none => return null,
        .up => -1,
        .down => 1,
    };

    // If our click pin no longer belongs to the active screen, the gesture is
    // no longer valid. Stop it so callers can stop their autoscroll timer
    // without clearing the current selection as if this were a real drag.
    _ = self.validatedLeftClickPin(&t.screens) orelse {
        self.reset(t);
        return null;
    };

    t.scrollViewport(.{ .delta = delta });

    const pin = t.screens.active.pages.pin(.{ .viewport = tick.viewport }) orelse return null;
    return self.drag(t, .{
        .pin = pin,
        .xpos = tick.xpos,
        .ypos = tick.ypos,
        .rectangle = tick.rectangle,
        .word_boundary_codepoints = tick.word_boundary_codepoints,
        .geometry = tick.geometry,
    });
}

/// A pressure-based activation during an existing left-click gesture.
///
/// This is the terminal gesture model for platform features such as macOS
/// force click / deep click on pressure-sensitive trackpads. It is not a
/// distinct mouse button and it is not part of the normal single/double/triple
/// click count sequence; it can only occur after a left press is already
/// active.
pub const DeepPress = struct {
    /// The codepoints that delimit words for the word selection produced by
    /// the deep press.
    word_boundary_codepoints: []const u21,
};

/// Record a deep press event for the active left-click gesture.
///
/// A deep press is a force/pressure activation while the primary pointer is
/// already down. Ghostty treats it like the platform text-selection affordance:
/// select the word under the original press, then consume the gesture so
/// further cursor movement while the button remains pressed does not drag or
/// autoscroll the selection.
///
/// After a successful deep press, the click sequence is cleared and the tracked
/// pin is untracked. The returned selection should be applied by the caller. A
/// null result means there was no valid active left-click anchor, commonly
/// because the screen changed or the gesture had already been cancelled.
pub fn deepPress(
    self: *SelectionGesture,
    t: *Terminal,
    p: DeepPress,
) ?Selection {
    const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null;
    const sel = t.screens.active.selectWord(
        click_pin.*,
        p.word_boundary_codepoints,
    );

    self.left_click_count = 0;
    self.left_click_time = null;
    self.left_click_behavior = .cell;
    self.left_click_dragged = true;
    self.left_drag_autoscroll = .none;
    self.untrackPin(t);

    return sel;
}

pub const Release = struct {
    /// The cell where the release occurred, if the release position mapped to
    /// a valid cell. This is used synchronously to update gesture state and is
    /// not tracked.
    pin: ?Pin,
};

/// Record a release event for the active left-click gesture.
///
/// This stops autoscroll and updates `left_click_dragged`, but it does not clear
/// the click count or time. Keeping that state is what lets the next nearby press
/// become a double- or triple-click. Call `reset` instead if the release should
/// cancel the click sequence entirely.
///
/// Pass the release pin when the pointer position maps to a valid terminal cell.
/// If it does not, pass null; the gesture then conservatively records that the
/// pointer moved away from the original pressed cell. This is useful for callers
/// that use `left_click_dragged` after release to decide whether a click should
/// activate links or other hit targets.
pub fn release(
    self: *SelectionGesture,
    t: *Terminal,
    r: Release,
) void {
    if (self.left_click_count == 0) {
        assert(self.left_drag_autoscroll == .none);
        return;
    }

    if (r.pin) |release_pin| {
        if (self.validatedLeftClickPin(&t.screens)) |click_pin| {
            if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true;
        } else {
            // If the original anchor is no longer valid, conservatively treat
            // this as a drag/cancelled click so callers don't perform click-only
            // actions on a different or recycled screen.
            self.left_click_dragged = true;
        }
    } else {
        self.left_click_dragged = true;
    }
    self.left_drag_autoscroll = .none;
}

fn pressInitial(
    self: *SelectionGesture,
    t: *Terminal,
    p: Press,
) Allocator.Error!void {
    // Setup our pin first, reusing our existing pin if we can.
    if (self.left_click_pin) |pin| {
        if (comptime std.debug.runtime_safety) {
            assert(self.left_click_screen == t.screens.active_key);
            assert(self.left_click_screen_generation == t.screens.generation(t.screens.active_key));
        }
        pin.* = p.pin;
    } else {
        const screens: *const ScreenSet = &t.screens;
        self.left_click_pin = try screens.active.pages.trackPin(p.pin);
        errdefer comptime unreachable;
        self.left_click_screen = screens.active_key;
        self.left_click_screen_generation = screens.generation(screens.active_key);
    }
    errdefer comptime unreachable;
    self.left_click_count = 1;
    self.left_click_behavior = p.behaviors[0];
    self.left_click_xpos = p.xpos;
    self.left_click_ypos = p.ypos;
    self.left_click_time = p.time;
    self.left_click_dragged = false;
    self.left_drag_autoscroll = .none;
}

fn pressRepeat(
    self: *SelectionGesture,
    t: *Terminal,
    p: Press,
) error{PressRequiresReset}!void {
    errdefer {
        self.left_click_count = 0;
        self.left_click_behavior = .cell;
        self.untrackPin(t);
    }

    // If too much time has passed then we always reset.
    const time = p.time orelse return error.PressRequiresReset;
    {
        const prev_time = self.left_click_time orelse return error.PressRequiresReset;
        const since = timeSince(time, prev_time) orelse
            return error.PressRequiresReset;
        if (since > p.repeat_interval) return error.PressRequiresReset;
    }

    // If the click is too far away from the initial click we can't continue.
    const distance = @sqrt(
        std.math.pow(f64, p.xpos - self.left_click_xpos, 2) +
            std.math.pow(f64, p.ypos - self.left_click_ypos, 2),
    );
    if (distance > p.max_distance) return error.PressRequiresReset;

    // If our prior click was on another screen then free and reset. "Another screen"
    // doesn't just mean alt vs primary, it could mean an alt screen that was
    // recycled since we free tracked pins on recycle.
    const screens: *const ScreenSet = &t.screens;
    if (self.left_click_screen != screens.active_key or
        screens.generation(self.left_click_screen) !=
            self.left_click_screen_generation)
    {
        // The error return will trigger the top-level errdefer which
        // will reset our pin.
        return error.PressRequiresReset;
    }

    self.left_click_time = time;
    self.left_click_dragged = false;
    self.left_drag_autoscroll = .none;
    self.left_click_count = @min(
        self.left_click_count + 1,
        3, // We only support triple clicks max
    );
    self.left_click_behavior = p.behaviors[self.left_click_count - 1];
}

fn timeSince(time: std.Io.Timestamp, prev_time: std.Io.Timestamp) ?u64 {
    const delta = prev_time.durationTo(time).toNanoseconds();
    if (delta < 0) return null;

    return @as(u64, @intCast(@min(delta, std.math.maxInt(u64))));
}

/// Convert a caller-provided floating-point position to a pixel coordinate.
/// Negative and NaN values clamp to the origin, matching the drag behavior
/// outside the left edge of the surface.
fn pixelFromFloat(value: f64) u32 {
    if (std.math.isNan(value) or value <= 0) return 0;

    // @intFromFloat requires a value representable by the destination type.
    // Saturate first so positive infinity and oversized coordinates are safe.
    const max: f64 = @floatFromInt(std.math.maxInt(u32));
    if (value >= max) return std.math.maxInt(u32);
    return @intFromFloat(value);
}

fn pressSelection(
    self: *const SelectionGesture,
    screen: *Screen,
    p: Press,
) ?Selection {
    return switch (self.left_click_behavior) {
        .cell => null,
        .word => screen.selectWord(p.pin, p.word_boundary_codepoints),
        .line => screen.selectLine(.{ .pin = p.pin }),
        .output => screen.selectOutput(p.pin),
    };
}

/// Calculates the appropriate selection given pins and pixel x positions for
/// the click point and the drag point, as well as selection mode and geometry.
fn dragSelection(
    click_pin: Pin,
    drag_pin: Pin,
    click_x: u32,
    drag_x: u32,
    rectangle_selection: bool,
    geometry: Drag.Geometry,
) ?Selection {
    // Explanation:
    //
    // # Normal selections
    //
    // ## Left-to-right selections
    // - The clicked cell is included if it was clicked to the left of its
    //   threshold point and the drag location is right of the threshold point.
    // - The cell under the cursor (the "drag cell") is included if the drag
    //   location is right of its threshold point.
    //
    // ## Right-to-left selections
    // - The clicked cell is included if it was clicked to the right of its
    //   threshold point and the drag location is left of the threshold point.
    // - The cell under the cursor (the "drag cell") is included if the drag
    //   location is left of its threshold point.
    //
    // # Rectangular selections
    //
    // Rectangular selections are handled similarly, except that
    // entire columns are considered rather than individual cells.

    if (geometry.columns == 0 or geometry.cell_width == 0) return null;

    // We only include cells in the selection if the threshold point lies
    // between the start and end points of the selection. A threshold of
    // 60% of the cell width was chosen empirically because it felt good.
    const threshold_point: u32 = @intFromFloat(@round(
        @as(f64, @floatFromInt(geometry.cell_width)) * 0.6,
    ));

    // We use this to clamp the pixel positions below.
    const pixel_span = std.math.mul(
        u32,
        geometry.columns,
        geometry.cell_width,
    ) catch std.math.maxInt(u32);
    const max_x = pixel_span - 1;

    // We need to know how far across in the cell the drag pos is, so
    // we subtract the padding and then take it modulo the cell width.
    const drag_x_frac = @min(max_x, drag_x -| geometry.padding_left) % geometry.cell_width;

    // We figure out the fractional part of the click x position similarly.
    const click_x_frac = @min(max_x, click_x -| geometry.padding_left) % geometry.cell_width;

    // Whether the click pin and drag pin are equal.
    const same_pin = drag_pin.eql(click_pin);

    // Whether or not the end point of our selection is before the start point.
    const end_before_start = ebs: {
        if (same_pin) {
            break :ebs drag_x_frac < click_x_frac;
        }

        // Special handling for rectangular selections, we only use x position.
        if (rectangle_selection) {
            break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) {
                .eq => drag_x_frac < click_x_frac,
                .lt => true,
                .gt => false,
            };
        }

        break :ebs drag_pin.before(click_pin);
    };

    // Whether or not the click pin cell
    // should be included in the selection.
    const include_click_cell = if (end_before_start)
        click_x_frac >= threshold_point
    else
        click_x_frac < threshold_point;

    // Whether or not the drag pin cell
    // should be included in the selection.
    const include_drag_cell = if (end_before_start)
        drag_x_frac < threshold_point
    else
        drag_x_frac >= threshold_point;

    // If the click cell should be included in the selection then it's the
    // start, otherwise we get the previous or next cell to it depending on
    // the type and direction of the selection.
    const start_pin =
        if (include_click_cell)
            click_pin
        else if (end_before_start)
            if (rectangle_selection)
                click_pin.leftClamp(1)
            else
                click_pin.leftWrap(1) orelse click_pin
        else if (rectangle_selection)
            click_pin.rightClamp(1)
        else
            click_pin.rightWrap(1) orelse click_pin;

    // Likewise for the end pin with the drag cell.
    const end_pin =
        if (include_drag_cell)
            drag_pin
        else if (end_before_start)
            if (rectangle_selection)
                drag_pin.rightClamp(1)
            else
                drag_pin.rightWrap(1) orelse drag_pin
        else if (rectangle_selection)
            drag_pin.leftClamp(1)
        else
            drag_pin.leftWrap(1) orelse drag_pin;

    // If the click cell is the same as the drag cell and the click cell
    // shouldn't be included, or if the cells are adjacent such that the
    // start or end pin becomes the other cell, and that cell should not
    // be included, then we have no selection, so we set it to null.
    //
    // If in rectangular selection mode, we compare columns as well.
    //
    // TODO(qwerasd): this can/should probably be refactored, it's a bit
    //                repetitive and does excess work in rectangle mode.
    if ((!include_click_cell and same_pin) or
        (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or
        (!include_click_cell and end_pin.eql(click_pin)) or
        (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or
        (!include_drag_cell and start_pin.eql(drag_pin)) or
        (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x))
    {
        return null;
    }

    // TODO: Clamp selection to the screen area, don't
    //       let it extend past the last written row.

    return .init(
        start_pin,
        end_pin,
        rectangle_selection,
    );
}

/// Calculates the appropriate word-wise selection for a double-click drag.
fn dragSelectionWord(
    screen: *Screen,
    click_pin: Pin,
    drag_pin: Pin,
    boundary_codepoints: []const u21,
) ?Selection {
    // Get the word closest to our starting click.
    const word_start = screen.selectWordBetween(
        click_pin,
        drag_pin,
        boundary_codepoints,
    ) orelse return null;

    // Get the word closest to our current point.
    const word_current = screen.selectWordBetween(
        drag_pin,
        click_pin,
        boundary_codepoints,
    ) orelse return null;

    // If our current mouse position is before the starting position,
    // then the selection start is the word nearest our current position.
    return if (drag_pin.before(click_pin))
        .init(
            word_current.start(),
            word_start.end(),
            false,
        )
    else
        .init(
            word_start.start(),
            word_current.end(),
            false,
        );
}

/// Calculates the appropriate line-wise selection for a triple-click drag.
fn dragSelectionLine(
    screen: *Screen,
    click_pin: Pin,
    drag_pin: Pin,
) ?Selection {
    // Get the line selection under our current drag point. If there isn't a
    // line, do nothing.
    const line = screen.selectLine(.{ .pin = drag_pin }) orelse return null;

    // Get the selection under our click point. We first try to trim
    // whitespace if we've selected a word. But if no word exists then
    // we select the blank line.
    const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
        screen.selectLine(.{ .pin = click_pin, .whitespace = null });

    var sel = sel_ orelse return null;
    if (drag_pin.before(click_pin)) {
        sel.startPtr().* = line.start();
    } else {
        sel.endPtr().* = line.end();
    }
    return sel;
}

/// Calculates the appropriate semantic-output-wise selection for an output
/// drag. This expands from the output block under the click point to the output
/// block under the current drag point. If the drag point is not output, keep the
/// original output selection.
fn dragSelectionOutput(
    screen: *Screen,
    click_pin: Pin,
    drag_pin: Pin,
) ?Selection {
    var sel = screen.selectOutput(click_pin) orelse return null;
    const current = screen.selectOutput(drag_pin) orelse return sel;

    if (drag_pin.before(click_pin)) {
        sel.startPtr().* = current.start();
    } else {
        sel.endPtr().* = current.end();
    }
    return sel;
}

fn untrackPin(self: *SelectionGesture, t: *Terminal) void {
    // Can't untrack unless we have a pin.
    const pin = self.left_click_pin orelse return;
    self.left_click_pin = null;

    // If the generation changed our pin is already invalid.
    const screens: *const ScreenSet = &t.screens;
    if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return;

    // If we can't get a screen then its already freed.
    const screen = screens.get(self.left_click_screen) orelse return;
    screen.pages.untrackPin(pin);
}

fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.Io.Timestamp) Press {
    return .{
        .time = time,
        .pin = t.screens.active.pages.pin(.{ .active = .{
            .x = x,
            .y = y,
        } }).?,
        .xpos = @floatFromInt(x),
        .ypos = @floatFromInt(y),
        .max_distance = 1,
        .repeat_interval = std.math.maxInt(u64),
        .word_boundary_codepoints = &.{},
    };
}

fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag {
    return .{
        .pin = t.screens.active.pages.pin(.{ .active = .{
            .x = x,
            .y = y,
        } }).?,
        .xpos = xpos,
        .ypos = ypos,
        .rectangle = false,
        .word_boundary_codepoints = &.{},
        .geometry = .{
            .columns = 5,
            .cell_width = 10,
            .padding_left = 0,
            .screen_height = 100,
        },
    };
}

fn testAutoscrollTick(
    viewport: point.Coordinate,
    xpos: f64,
    ypos: f64,
) AutoscrollTick {
    return .{
        .viewport = viewport,
        .xpos = xpos,
        .ypos = ypos,
        .rectangle = false,
        .word_boundary_codepoints = &.{},
        .geometry = .{
            .columns = 5,
            .cell_width = 10,
            .padding_left = 0,
            .screen_height = 100,
        },
    };
}

fn testPin(t: *Terminal, x: u16, y: u32) Pin {
    return t.screens.active.pages.pin(.{ .active = .{
        .x = x,
        .y = y,
    } }).?;
}

/// Utility function for the unit tests for drag selection logic.
///
/// Tests a click and drag on a 10x5 cell grid, x positions are given in
/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3.
///
/// NOTE: The geometry tested with has 10px wide cells, meaning only one digit
///       after the decimal place has any meaning, e.g. 3.14 is equal to 3.1.
///
/// The provided start_x/y and end_x/y are the expected start and end points
/// of the resulting selection.
fn testDragSelection(
    click_x: f64,
    click_y: u32,
    drag_x: f64,
    drag_y: u32,
    start_x: u16,
    start_y: u32,
    end_x: u16,
    end_y: u32,
    rect: bool,
) !void {
    assert(@import("builtin").is_test);

    // Our screen size is 10x5 cells that are
    // 10x20 px, with 5px padding on all sides.
    const geometry: Drag.Geometry = .{
        .columns = 10,
        .cell_width = 10,
        .padding_left = 5,
        .screen_height = 110,
    };
    var screen = try Screen.init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 });
    defer screen.deinit();

    const click_pin = screen.pages.pin(.{
        .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
    }) orelse unreachable;
    const drag_pin = screen.pages.pin(.{
        .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
    }) orelse unreachable;

    const cell_width_f64: f64 = @floatFromInt(geometry.cell_width);
    const click_x_pos: u32 =
        @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
        geometry.padding_left;
    const drag_x_pos: u32 =
        @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
        geometry.padding_left;

    const start_pin = screen.pages.pin(.{
        .viewport = .{ .x = start_x, .y = start_y },
    }) orelse unreachable;
    const end_pin = screen.pages.pin(.{
        .viewport = .{ .x = end_x, .y = end_y },
    }) orelse unreachable;

    try testing.expectEqualDeep(Selection{
        .bounds = .{ .untracked = .{
            .start = start_pin,
            .end = end_pin,
        } },
        .rectangle = rect,
    }, dragSelection(
        click_pin,
        drag_pin,
        click_x_pos,
        drag_x_pos,
        rect,
        geometry,
    ));
}

/// Like `testDragSelection` but checks that the resulting selection is null.
///
/// See `testDragSelection` for more details.
fn testDragSelectionIsNull(
    click_x: f64,
    click_y: u32,
    drag_x: f64,
    drag_y: u32,
    rect: bool,
) !void {
    assert(@import("builtin").is_test);

    // Our screen size is 10x5 cells that are
    // 10x20 px, with 5px padding on all sides.
    const geometry: Drag.Geometry = .{
        .columns = 10,
        .cell_width = 10,
        .padding_left = 5,
        .screen_height = 110,
    };
    var screen = try Screen.init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 });
    defer screen.deinit();

    const click_pin = screen.pages.pin(.{
        .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
    }) orelse unreachable;
    const drag_pin = screen.pages.pin(.{
        .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
    }) orelse unreachable;

    const cell_width_f64: f64 = @floatFromInt(geometry.cell_width);
    const click_x_pos: u32 =
        @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
        geometry.padding_left;
    const drag_x_pos: u32 =
        @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
        geometry.padding_left;

    try testing.expectEqual(
        null,
        dragSelection(
            click_pin,
            drag_pin,
            click_x_pos,
            drag_x_pos,
            rect,
            geometry,
        ),
    );
}

test "SelectionGesture drag selection logic" {
    // We disable format to make these easier to
    // read by pairing sets of coordinates per line.
    // zig fmt: off

    // -- LTR
    // single cell selection
    try testDragSelection(
        3.0, 3, // click
        3.9, 3, // drag
        3, 3, // expected start
        3, 3, // expected end
        false, // regular selection
    );
    // including click and drag pin cells
    try testDragSelection(
        3.0, 3, // click
        5.9, 3, // drag
        3, 3, // expected start
        5, 3, // expected end
        false, // regular selection
    );
    // including click pin cell but not drag pin cell
    try testDragSelection(
        3.0, 3, // click
        5.0, 3, // drag
        3, 3, // expected start
        4, 3, // expected end
        false, // regular selection
    );
    // including drag pin cell but not click pin cell
    try testDragSelection(
        3.9, 3, // click
        5.9, 3, // drag
        4, 3, // expected start
        5, 3, // expected end
        false, // regular selection
    );
    // including neither click nor drag pin cells
    try testDragSelection(
        3.9, 3, // click
        5.0, 3, // drag
        4, 3, // expected start
        4, 3, // expected end
        false, // regular selection
    );
    // empty selection (single cell on only left half)
    try testDragSelectionIsNull(
        3.0, 3, // click
        3.1, 3, // drag
        false, // regular selection
    );
    // empty selection (single cell on only right half)
    try testDragSelectionIsNull(
        3.8, 3, // click
        3.9, 3, // drag
        false, // regular selection
    );
    // empty selection (between two cells, not crossing threshold)
    try testDragSelectionIsNull(
        3.9, 3, // click
        4.0, 3, // drag
        false, // regular selection
    );

    // -- RTL
    // single cell selection
    try testDragSelection(
        3.9, 3, // click
        3.0, 3, // drag
        3, 3, // expected start
        3, 3, // expected end
        false, // regular selection
    );
    // including click and drag pin cells
    try testDragSelection(
        5.9, 3, // click
        3.0, 3, // drag
        5, 3, // expected start
        3, 3, // expected end
        false, // regular selection
    );
    // including click pin cell but not drag pin cell
    try testDragSelection(
        5.9, 3, // click
        3.9, 3, // drag
        5, 3, // expected start
        4, 3, // expected end
        false, // regular selection
    );
    // including drag pin cell but not click pin cell
    try testDragSelection(
        5.0, 3, // click
        3.0, 3, // drag
        4, 3, // expected start
        3, 3, // expected end
        false, // regular selection
    );
    // including neither click nor drag pin cells
    try testDragSelection(
        5.0, 3, // click
        3.9, 3, // drag
        4, 3, // expected start
        4, 3, // expected end
        false, // regular selection
    );
    // empty selection (single cell on only left half)
    try testDragSelectionIsNull(
        3.1, 3, // click
        3.0, 3, // drag
        false, // regular selection
    );
    // empty selection (single cell on only right half)
    try testDragSelectionIsNull(
        3.9, 3, // click
        3.8, 3, // drag
        false, // regular selection
    );
    // empty selection (between two cells, not crossing threshold)
    try testDragSelectionIsNull(
        4.0, 3, // click
        3.9, 3, // drag
        false, // regular selection
    );

    // -- Wrapping
    // LTR, wrap excluded cells
    try testDragSelection(
        9.9, 2, // click
        0.0, 4, // drag
        0, 3, // expected start
        9, 3, // expected end
        false, // regular selection
    );
    // RTL, wrap excluded cells
    try testDragSelection(
        0.0, 4, // click
        9.9, 2, // drag
        9, 3, // expected start
        0, 3, // expected end
        false, // regular selection
    );
}

test "SelectionGesture rectangle drag selection logic" {
    // We disable format to make these easier to
    // read by pairing sets of coordinates per line.
    // zig fmt: off

    // -- LTR
    // single column selection
    try testDragSelection(
        3.0, 2, // click
        3.9, 4, // drag
        3, 2, // expected start
        3, 4, // expected end
        true, //rectangle selection
    );
    // including click and drag pin columns
    try testDragSelection(
        3.0, 2, // click
        5.9, 4, // drag
        3, 2, // expected start
        5, 4, // expected end
        true, //rectangle selection
    );
    // including click pin column but not drag pin column
    try testDragSelection(
        3.0, 2, // click
        5.0, 4, // drag
        3, 2, // expected start
        4, 4, // expected end
        true, //rectangle selection
    );
    // including drag pin column but not click pin column
    try testDragSelection(
        3.9, 2, // click
        5.9, 4, // drag
        4, 2, // expected start
        5, 4, // expected end
        true, //rectangle selection
    );
    // including neither click nor drag pin columns
    try testDragSelection(
        3.9, 2, // click
        5.0, 4, // drag
        4, 2, // expected start
        4, 4, // expected end
        true, //rectangle selection
    );
    // empty selection (single column on only left half)
    try testDragSelectionIsNull(
        3.0, 2, // click
        3.1, 4, // drag
        true, //rectangle selection
    );
    // empty selection (single column on only right half)
    try testDragSelectionIsNull(
        3.8, 2, // click
        3.9, 4, // drag
        true, //rectangle selection
    );
    // empty selection (between two columns, not crossing threshold)
    try testDragSelectionIsNull(
        3.9, 2, // click
        4.0, 4, // drag
        true, //rectangle selection
    );

    // -- RTL
    // single column selection
    try testDragSelection(
        3.9, 2, // click
        3.0, 4, // drag
        3, 2, // expected start
        3, 4, // expected end
        true, //rectangle selection
    );
    // including click and drag pin columns
    try testDragSelection(
        5.9, 2, // click
        3.0, 4, // drag
        5, 2, // expected start
        3, 4, // expected end
        true, //rectangle selection
    );
    // including click pin column but not drag pin column
    try testDragSelection(
        5.9, 2, // click
        3.9, 4, // drag
        5, 2, // expected start
        4, 4, // expected end
        true, //rectangle selection
    );
    // including drag pin column but not click pin column
    try testDragSelection(
        5.0, 2, // click
        3.0, 4, // drag
        4, 2, // expected start
        3, 4, // expected end
        true, //rectangle selection
    );
    // including neither click nor drag pin columns
    try testDragSelection(
        5.0, 2, // click
        3.9, 4, // drag
        4, 2, // expected start
        4, 4, // expected end
        true, //rectangle selection
    );
    // empty selection (single column on only left half)
    try testDragSelectionIsNull(
        3.1, 2, // click
        3.0, 4, // drag
        true, //rectangle selection
    );
    // empty selection (single column on only right half)
    try testDragSelectionIsNull(
        3.9, 2, // click
        3.8, 4, // drag
        true, //rectangle selection
    );
    // empty selection (between two columns, not crossing threshold)
    try testDragSelectionIsNull(
        4.0, 2, // click
        3.9, 4, // drag
        true, //rectangle selection
    );

    // -- Wrapping
    // LTR, do not wrap
    try testDragSelection(
        9.9, 2, // click
        0.0, 4, // drag
        9, 2, // expected start
        0, 4, // expected end
        true, //rectangle selection
    );
    // RTL, do not wrap
    try testDragSelection(
        0.0, 4, // click
        9.9, 2, // drag
        0, 4, // expected start
        9, 2, // expected end
        true, //rectangle selection
    );
}

test "SelectionGesture press records initial click" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 2, time));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqual(time, gesture.left_click_time.?);
    try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos);
    try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos);
    try testing.expectEqual(false, gesture.left_click_dragged);
}

test "SelectionGesture press returns standard click selections" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta\none two");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    var event = testPress(&t, 1, 0, time);
    event.word_boundary_codepoints = &.{ ' ' };

    try testing.expectEqual(null, try gesture.press(&t, event));

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 4, 0),
        false,
    ), (try gesture.press(&t, event)).?);

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 0),
        false,
    ), (try gesture.press(&t, event)).?);
}

test "SelectionGesture press behaviors choose press and drag behavior" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta\none two\nthree four");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    var event = testPress(&t, 1, 0, time);
    event.behaviors = &.{ .cell, .line, .word };
    event.word_boundary_codepoints = &.{ ' ' };

    _ = try gesture.press(&t, event);
    try testing.expectEqual(.cell, gesture.left_click_behavior);

    const double_click = (try gesture.press(&t, event)).?;
    try testing.expectEqual(.line, gesture.left_click_behavior);
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 0),
        false,
    ), double_click);

    const line_drag = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?;
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 2),
        false,
    ), line_drag);
}

test "SelectionGesture output behavior selects and drags semantic output" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 10, .rows = 6 });
    defer t.deinit(testing.allocator);

    const screen = t.screens.active;
    screen.cursorSetSemanticContent(.output);
    try screen.testWriteString("out1\n");
    screen.cursorSetSemanticContent(.{ .prompt = .initial });
    try screen.testWriteString("$ ");
    screen.cursorSetSemanticContent(.{ .input = .clear_explicit });
    try screen.testWriteString("cmd\n");
    screen.cursorSetSemanticContent(.output);
    try screen.testWriteString("out2");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var event = testPress(&t, 1, 0, std.Io.Timestamp.now(testing.io, .awake));
    event.behaviors = &.{ .output, .word, .line };

    const press_selection = (try gesture.press(&t, event)).?;
    try testing.expectEqual(.output, gesture.left_click_behavior);
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 3, 0),
        false,
    ), press_selection);

    const output_drag = gesture.drag(&t, testDrag(&t, 1, 2, 10, 50)).?;
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 3, 2),
        false,
    ), output_drag);
}

test "SelectionGesture drag returns selection and records autoscroll" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?;
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
    try testing.expectEqual(true, gesture.left_click_dragged);

    try testing.expectEqualDeep(Selection.init(
        t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
        t.screens.active.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
        false,
    ), sel);

    _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);

    _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100));
    try testing.expectEqual(.down, gesture.left_drag_autoscroll);
}

test "SelectionGesture drag clamps unrepresentable positions" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    const positive = gesture.drag(
        &t,
        testDrag(&t, 1, 1, std.math.inf(f64), 50),
    ).?;
    try testing.expect((testPin(&t, 1, 1)).eql(positive.start()));
    try testing.expect((testPin(&t, 1, 1)).eql(positive.end()));

    try testing.expectEqual(null, gesture.drag(
        &t,
        testDrag(&t, 1, 1, std.math.nan(f64), 50),
    ));
}

test "SelectionGesture drag saturates overflowing geometry" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    var drag_event = testDrag(&t, 1, 1, 10, 50);
    drag_event.geometry.columns = std.math.maxInt(u32);
    drag_event.geometry.cell_width = std.math.maxInt(u32);
    try testing.expectEqual(null, gesture.drag(&t, drag_event));
}

test "SelectionGesture drag rejects empty geometry" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    var drag_event = testDrag(&t, 1, 1, 10, 50);
    drag_event.geometry.columns = 0;
    drag_event.geometry.cell_width = 0;
    try testing.expectEqual(null, gesture.drag(&t, drag_event));
}

test "SelectionGesture release clears autoscroll and records drag" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    try testing.expectEqual(false, gesture.left_click_dragged);

    _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);
    try testing.expectEqual(false, gesture.left_click_dragged);

    gesture.release(&t, .{
        .pin = testPin(&t, 2, 1),
    });
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
    try testing.expectEqual(true, gesture.left_click_dragged);
}

test "SelectionGesture release with invalidated click records drag" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    try testing.expectEqual(false, gesture.left_click_dragged);

    _ = try t.screens.getInit(testing.io, testing.allocator, .alternate, .{
        .cols = t.cols,
        .rows = t.rows,
    });
    t.screens.switchTo(.alternate);

    gesture.release(&t, .{ .pin = testPin(&t, 1, 1) });
    try testing.expectEqual(true, gesture.left_click_dragged);
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
}

test "SelectionGesture same-cell threshold selection records drag" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);
    try testing.expectEqual(false, gesture.left_click_dragged);

    const sel = gesture.drag(&t, testDrag(&t, 1, 1, 19, 50)).?;
    try testing.expectEqual(true, gesture.left_click_dragged);
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 1, 1),
        testPin(&t, 1, 1),
        false,
    ), sel);
}

test "SelectionGesture drag without press returns null" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 1, 1, 10, 50)));
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
}

test "SelectionGesture drag autoscroll edge boundaries" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1.1));
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99));
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99.1));
    try testing.expectEqual(.down, gesture.left_drag_autoscroll);
}

test "SelectionGesture autoscroll tick scrolls and continues drag" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100));
    try testing.expectEqual(.down, gesture.left_drag_autoscroll);

    const sel = gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 3, .y = 2 }, 39, 100)).?;
    try testing.expectEqual(.down, gesture.left_drag_autoscroll);
    try testing.expectEqual(true, gesture.left_click_dragged);
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 1, 1),
        testPin(&t, 3, 2),
        false,
    ), sel);
}

test "SelectionGesture autoscroll tick resolves drag pin after scrolling" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 10 });
    defer t.deinit(testing.allocator);
    try t.printString("1111\n2222\n3333\n4444\n5555");
    t.scrollViewport(.{ .delta = -2 });

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    _ = gesture.drag(&t, testDrag(&t, 3, 2, 39, 100));
    try testing.expectEqual(.down, gesture.left_drag_autoscroll);

    const viewport: point.Coordinate = .{ .x = 3, .y = 2 };
    const pre_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?;
    const sel = gesture.autoscrollTick(&t, testAutoscrollTick(viewport, 39, 100)).?;
    const post_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?;

    try testing.expect(!pre_scroll_pin.eql(post_scroll_pin));
    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 1, 1),
        post_scroll_pin,
        false,
    ), sel);
}

test "SelectionGesture autoscroll tick stops with invalidated click" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);

    _ = try t.screens.getInit(testing.io, testing.allocator, .alternate, .{
        .cols = t.cols,
        .rows = t.rows,
    });
    t.screens.switchTo(.alternate);

    try testing.expectEqual(null, gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 2, .y = 1 }, 20, 1)));
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
    try testing.expectEqual(@as(u3, 0), gesture.left_click_count);
}

test "SelectionGesture deep press selects word and consumes drag" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 0, std.Io.Timestamp.now(testing.io, .awake)));
    _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);

    const sel = gesture.deepPress(&t, .{
        .word_boundary_codepoints = &.{ ' ' },
    }).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 4, 0),
        false,
    ), sel);
    try testing.expectEqual(@as(u3, 0), gesture.left_click_count);
    try testing.expectEqual(@as(?std.Io.Timestamp, null), gesture.left_click_time);
    try testing.expectEqual(true, gesture.left_click_dragged);
    try testing.expectEqual(.none, gesture.left_drag_autoscroll);
    try testing.expect(gesture.left_click_pin == null);

    try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 7, 0, 70, 50)));
    gesture.release(&t, .{ .pin = testPin(&t, 7, 0) });
    try testing.expectEqual(true, gesture.left_click_dragged);
}

test "SelectionGesture drag with invalidated click returns null" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var press_event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    press_event.xpos = 10;
    _ = try gesture.press(&t, press_event);

    _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);

    _ = try t.screens.getInit(testing.io, testing.allocator, .alternate, .{
        .cols = t.cols,
        .rows = t.rows,
    });
    t.screens.switchTo(.alternate);

    try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 2, 1, 20, 50)));
    try testing.expectEqual(.up, gesture.left_drag_autoscroll);
}

test "SelectionGesture double-click drag selects by word" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta gamma");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));

    var drag_event = testDrag(&t, 7, 0, 70, 50);
    drag_event.word_boundary_codepoints = &.{ ' ' };
    const sel = gesture.drag(&t, drag_event).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 0),
        false,
    ), sel);
}

test "SelectionGesture double-click drag selects by word backwards" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta gamma");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 7, 0, time));
    _ = try gesture.press(&t, testPress(&t, 7, 0, time));

    var drag_event = testDrag(&t, 1, 0, 10, 50);
    drag_event.word_boundary_codepoints = &.{ ' ' };
    const sel = gesture.drag(&t, drag_event).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 0),
        false,
    ), sel);
}

test "SelectionGesture double-click drag on empty cell selects nearest word" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));

    var drag_event = testDrag(&t, 15, 0, 150, 50);
    drag_event.word_boundary_codepoints = &.{ ' ' };
    const sel = gesture.drag(&t, drag_event).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 0),
        false,
    ), sel);
}

test "SelectionGesture triple-click drag selects by line" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta\none two\nthree four");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));
    _ = try gesture.press(&t, testPress(&t, 1, 0, time));

    const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 2),
        false,
    ), sel);
}

test "SelectionGesture triple-click drag selects by line backwards" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 20, .rows = 5 });
    defer t.deinit(testing.allocator);
    try t.printString("alpha beta\none two\nthree four");

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 2, 2, time));
    _ = try gesture.press(&t, testPress(&t, 2, 2, time));
    _ = try gesture.press(&t, testPress(&t, 2, 2, time));

    const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?;

    try testing.expectEqualDeep(Selection.init(
        testPin(&t, 0, 0),
        testPin(&t, 9, 2),
        false,
    ), sel);
}

test "SelectionGesture repeat increments click count" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 1, time));
    _ = try gesture.press(&t, testPress(&t, 1, 1, time));

    try testing.expectEqual(@as(u3, 2), gesture.left_click_count);
}

test "SelectionGesture repeat clamps at triple click" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    for (0..4) |_| _ = try gesture.press(&t, testPress(&t, 1, 1, time));

    try testing.expectEqual(@as(u3, 3), gesture.left_click_count);
}

test "SelectionGesture null initial time stays single click" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, null));
    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expect(gesture.left_click_time != null);
}

test "SelectionGesture null repeat time stays single click" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    _ = try gesture.press(&t, testPress(&t, 1, 1, null));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqual(@as(?std.Io.Timestamp, null), gesture.left_click_time);
}

test "SelectionGesture distant press resets click count" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, testPress(&t, 1, 1, time));
    _ = try gesture.press(&t, testPress(&t, 4, 1, time));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos);
}

test "SelectionGesture expired repeat resets click count" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    var event = testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake));
    event.repeat_interval = 0;
    _ = try gesture.press(&t, event);

    try std.Io.sleep(testing.io, std.Io.Duration.fromMilliseconds(1), .awake);
    event.time = std.Io.Timestamp.now(testing.io, .awake);
    _ = try gesture.press(&t, event);

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
}

test "SelectionGesture backwards repeat time resets click count" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const now: std.Io.Timestamp = .now(testing.io, .awake);
    const earlier = now.addDuration(.fromSeconds(1));
    const later = now.addDuration(.fromSeconds(2));
    _ = try gesture.press(&t, testPress(&t, 1, 1, later));
    _ = try gesture.press(&t, testPress(&t, 1, 1, earlier));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqualDeep(earlier, gesture.left_click_time.?);
}

test "SelectionGesture screen switch resets click count" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    const time = std.Io.Timestamp.now(testing.io, .awake);
    const primary_tracked = t.screens.active.pages.countTrackedPins();
    _ = try gesture.press(&t, testPress(&t, 1, 1, time));

    _ = try t.screens.getInit(testing.io, testing.allocator, .alternate, .{
        .cols = t.cols,
        .rows = t.rows,
    });
    t.screens.switchTo(.alternate);
    _ = try gesture.press(&t, testPress(&t, 1, 1, time));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqual(.alternate, gesture.left_click_screen);
    try testing.expectEqual(primary_tracked, t.screens.get(.primary).?.pages.countTrackedPins());
}

test "SelectionGesture removed screen resets without untracking stale pin" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    defer gesture.deinit(&t);

    _ = try t.screens.getInit(testing.io, testing.allocator, .alternate, .{
        .cols = t.cols,
        .rows = t.rows,
    });
    t.screens.switchTo(.alternate);
    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));

    t.screens.switchTo(.primary);
    t.screens.remove(testing.allocator, .alternate);
    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));

    try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
    try testing.expectEqual(.primary, gesture.left_click_screen);
}

test "SelectionGesture deinit untracks pin" {
    var t = try Terminal.init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 });
    defer t.deinit(testing.allocator);

    var gesture: SelectionGesture = .init;
    const tracked = t.screens.active.pages.countTrackedPins();
    _ = try gesture.press(&t, testPress(&t, 1, 1, std.Io.Timestamp.now(testing.io, .awake)));
    try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins());

    gesture.deinit(&t);
    try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
}
