mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-28 15:55:20 +00:00
2009 lines
69 KiB
Zig
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());
|
|
}
|