Files
ghostty/src/terminal/SelectionGesture.zig
2026-05-27 09:02:25 -07:00

2009 lines
69 KiB
Zig

/// 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 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");
/// 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.time.Instant,
/// 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. Use a monotonic timer.
/// 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.time.Instant,
/// 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,
@intFromFloat(@max(0, self.left_click_xpos)),
@intFromFloat(@max(0, 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 = time.since(prev_time);
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 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.
// 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 max_x = geometry.columns * geometry.cell_width - 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.time.Instant) 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.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 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.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 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.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
_ = 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.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 = try std.time.Instant.now();
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.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 = try std.time.Instant.now();
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.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, try std.time.Instant.now());
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.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, try std.time.Instant.now());
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 release clears autoscroll and records drag" {
var t = try Terminal.init(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, try std.time.Instant.now()));
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.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, try std.time.Instant.now()));
try testing.expectEqual(false, gesture.left_click_dragged);
_ = try t.screens.getInit(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.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, try std.time.Instant.now());
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.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.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, try std.time.Instant.now());
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.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, try std.time.Instant.now());
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.allocator, .{ .cols = 5, .rows = 3, .max_scrollback = 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, try std.time.Instant.now());
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.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, try std.time.Instant.now());
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.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.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, try std.time.Instant.now()));
_ = 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.time.Instant, 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.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, try std.time.Instant.now());
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.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.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 = try std.time.Instant.now();
_ = 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.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 = try std.time.Instant.now();
_ = 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.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 = try std.time.Instant.now();
_ = 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.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 = try std.time.Instant.now();
_ = 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.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 = try std.time.Instant.now();
_ = 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.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
_ = 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.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
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.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, try std.time.Instant.now()));
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.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, try std.time.Instant.now()));
_ = try gesture.press(&t, testPress(&t, 1, 1, null));
try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time);
}
test "SelectionGesture distant press resets click count" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
_ = 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.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
var event = testPress(&t, 1, 1, try std.time.Instant.now());
event.repeat_interval = 0;
_ = try gesture.press(&t, event);
std.Thread.sleep(std.time.ns_per_ms);
event.time = try std.time.Instant.now();
_ = try gesture.press(&t, event);
try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
}
test "SelectionGesture screen switch resets click count" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
const primary_tracked = t.screens.active.pages.countTrackedPins();
_ = try gesture.press(&t, testPress(&t, 1, 1, time));
_ = try t.screens.getInit(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.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
_ = try t.screens.getInit(testing.allocator, .alternate, .{
.cols = t.cols,
.rows = t.rows,
});
t.screens.switchTo(.alternate);
_ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
t.screens.switchTo(.primary);
t.screens.remove(testing.allocator, .alternate);
_ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
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.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, try std.time.Instant.now()));
try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins());
gesture.deinit(&t);
try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
}