SelectionGesture: drag events

This commit is contained in:
Mitchell Hashimoto
2026-05-26 20:34:44 -07:00
parent 33f1558801
commit c00cdd886b
2 changed files with 764 additions and 630 deletions

View File

@@ -250,12 +250,7 @@ const Mouse = struct {
/// Return the left-click pin only if it still belongs to the active screen.
fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin {
const gesture = &self.selection_gesture;
const pin = gesture.left_click_pin orelse return null;
if (gesture.left_click_screen != screens.active_key) return null;
if (screens.generation(gesture.left_click_screen) != gesture.left_click_screen_generation) return null;
_ = screens.get(gesture.left_click_screen) orelse return null;
return pin;
return self.selection_gesture.validatedLeftClickPin(screens);
}
};
@@ -1180,9 +1175,14 @@ fn selectionScrollTick(self: *Surface) !void {
// don't do anything.
if (self.mouse.selection_gesture.left_click_count == 0) return;
const delta: isize = switch (self.mouse.selection_gesture.left_drag_autoscroll) {
.none => return,
.up => -1,
.down => 1,
};
const pos = try self.rt_surface.getCursorPos();
const pos_vp = self.posToViewport(pos.x, pos.y);
const delta: isize = if (pos.y < 0) -1 else 1;
// We need our locked state for the remainder
self.renderer_state.mutex.lock();
@@ -1212,7 +1212,22 @@ fn selectionScrollTick(self: *Surface) !void {
if (comptime std.debug.runtime_safety) unreachable;
return;
};
try self.dragLeftClickSingle(pin, pos.x);
if (self.mouse.selection_gesture.drag(t, .{
.pin = pin,
.xpos = pos.x,
.ypos = pos.y,
.rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
.geometry = .{
.columns = @intCast(self.size.grid().columns),
.cell_width = self.size.cell.width,
.padding_left = self.size.padding.left,
.screen_height = self.size.screen.height,
},
})) |sel| {
try self.io.terminal.screens.active.select(sel);
} else {
try self.io.terminal.screens.active.select(null);
}
// We modified our viewport and selection so we need to queue
// a render.
@@ -3807,6 +3822,7 @@ pub fn mouseButtonCallback(
.locked,
);
}
self.mouse.selection_gesture.left_drag_autoscroll = .none;
// The selection clipboard is only updated for left-click drag when
// the left button is released. This is to avoid the clipboard
@@ -4515,15 +4531,6 @@ pub fn cursorPosCallback(
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// Stop selection scrolling when inside the viewport within a 1px buffer
// for fullscreen windows, but only when selection scrolling is active.
if (pos.y >= 1 and self.selection_scroll_active) {
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
}
// Update our mouse state. We set this to null initially because we only
// want to set it when we're not selecting or doing any other mouse
// event.
@@ -4606,25 +4613,6 @@ pub fn cursorPosCallback(
// All roads lead to requiring a re-render at this point.
try self.queueRender();
// If our y is negative, we're above the window. In this case, we scroll
// up. The amount we scroll up is dependent on how negative we are.
// We allow for a 1 pixel buffer at the top and bottom to detect
// scroll even in full screen windows.
// Note: one day, we can change this from distance to time based if we want.
//log.warn("CURSOR POS: {} {}", .{ pos, self.size.screen });
const max_y: f32 = @floatFromInt(self.size.screen.height);
// If the mouse is outside the viewport and we have the left
// mouse button pressed then we need to start the scroll timer.
if ((pos.y <= 1 or pos.y > max_y - 1) and
!self.selection_scroll_active)
{
self.queueIo(
.{ .selection_scroll = true },
.locked,
);
}
// Convert to points
const screen: *terminal.Screen = t.screens.active;
const pin = screen.pages.pin(.{
@@ -4637,9 +4625,37 @@ pub fn cursorPosCallback(
return;
};
const drag_selection = self.mouse.selection_gesture.drag(t, .{
.pin = pin,
.xpos = pos.x,
.ypos = pos.y,
.rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
.geometry = .{
.columns = @intCast(self.size.grid().columns),
.cell_width = self.size.cell.width,
.padding_left = self.size.padding.left,
.screen_height = self.size.screen.height,
},
});
switch (self.mouse.selection_gesture.left_drag_autoscroll) {
.none => if (self.selection_scroll_active) {
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
},
.up, .down => if (!self.selection_scroll_active) {
self.queueIo(
.{ .selection_scroll = true },
.locked,
);
},
}
// Handle dragging depending on click count
switch (self.mouse.selection_gesture.left_click_count) {
1 => try self.dragLeftClickSingle(pin, pos.x),
1 => try self.io.terminal.screens.active.select(drag_selection),
2 => try self.dragLeftClickDouble(pin),
3 => try self.dragLeftClickTriple(pin),
0 => unreachable, // handled above
@@ -4726,172 +4742,6 @@ fn dragLeftClickTriple(
try self.io.terminal.screens.active.select(sel);
}
fn dragLeftClickSingle(
self: *Surface,
drag_pin: terminal.Pin,
drag_x: f64,
) !void {
// This logic is in a separate function so that it can be unit tested.
const click_pin: terminal.Pin = pin: {
const set: *terminal.ScreenSet = &self.io.terminal.screens;
const tracked = self.mouse.activeLeftClickPin(set) orelse return;
break :pin tracked.*;
};
try self.io.terminal.screens.active.select(mouseSelection(
click_pin,
drag_pin,
@intFromFloat(@max(0.0, self.mouse.selection_gesture.left_click_xpos)),
@intFromFloat(@max(0.0, drag_x)),
self.mouse.mods,
self.size,
));
}
/// Calculates the appropriate selection given pins and pixel x positions for
/// the click point and the drag point, as well as mouse mods and screen size.
fn mouseSelection(
click_pin: terminal.Pin,
drag_pin: terminal.Pin,
click_x: u32,
drag_x: u32,
mods: input.Mods,
size: rendererpkg.Size,
) ?terminal.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(size.cell.width)) * 0.6,
));
// We use this to clamp the pixel positions below.
const max_x = size.grid().columns * size.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 -| size.padding.left) % size.cell.width;
// We figure out the fractional part of the click x position similarly.
const click_x_frac = @min(max_x, click_x -| size.padding.left) % size.cell.width;
// Whether or not this is a rectangular selection.
const rectangle_selection = SurfaceMouse.isRectangleSelectState(mods);
// 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,
);
}
/// Call to notify Ghostty that the color scheme for the terminal has
/// changed.
pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {
@@ -6220,436 +6070,9 @@ fn presentSurface(self: *Surface) !void {
);
}
/// Utility function for the unit tests for mouse 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 size 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 testMouseSelection(
click_x: f64,
click_y: u32,
drag_x: f64,
drag_y: u32,
start_x: terminal.size.CellCountInt,
start_y: u32,
end_x: terminal.size.CellCountInt,
end_y: u32,
rect: bool,
) !void {
assert(builtin.is_test);
// Our screen size is 10x5 cells that are
// 10x20 px, with 5px padding on all sides.
const size: rendererpkg.Size = .{
.cell = .{ .width = 10, .height = 20 },
.padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
.screen = .{ .width = 110, .height = 110 },
};
var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
defer screen.deinit();
// We hold both ctrl and alt for rectangular
// select so that this test is platform agnostic.
const mods: input.Mods = .{
.ctrl = rect,
.alt = rect,
};
try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
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(size.cell.width);
const click_x_pos: u32 =
@as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
size.padding.left;
const drag_x_pos: u32 =
@as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
size.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 std.testing.expectEqualDeep(terminal.Selection{
.bounds = .{ .untracked = .{
.start = start_pin,
.end = end_pin,
} },
.rectangle = rect,
}, mouseSelection(
click_pin,
drag_pin,
click_x_pos,
drag_x_pos,
mods,
size,
));
}
/// Like `testMouseSelection` but checks that the resulting selection is null.
///
/// See `testMouseSelection` for more details.
fn testMouseSelectionIsNull(
click_x: f64,
click_y: u32,
drag_x: f64,
drag_y: u32,
rect: bool,
) !void {
assert(builtin.is_test);
// Our screen size is 10x5 cells that are
// 10x20 px, with 5px padding on all sides.
const size: rendererpkg.Size = .{
.cell = .{ .width = 10, .height = 20 },
.padding = .{ .left = 5, .top = 5, .right = 5, .bottom = 5 },
.screen = .{ .width = 110, .height = 110 },
};
var screen = try terminal.Screen.init(std.testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
defer screen.deinit();
// We hold both ctrl and alt for rectangular
// select so that this test is platform agnostic.
const mods: input.Mods = .{
.ctrl = rect,
.alt = rect,
};
try std.testing.expectEqual(rect, SurfaceMouse.isRectangleSelectState(mods));
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(size.cell.width);
const click_x_pos: u32 =
@as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
size.padding.left;
const drag_x_pos: u32 =
@as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
size.padding.left;
try std.testing.expectEqual(
null,
mouseSelection(
click_pin,
drag_pin,
click_x_pos,
drag_x_pos,
mods,
size,
),
);
}
/// Get information about the process(es) running within the surface. Returns
/// `null` if there was an error getting the information or the information is
/// not available on a particular platform.
pub fn getProcessInfo(self: *Surface, comptime info: ProcessInfo) ?ProcessInfo.Type(info) {
return self.io.getProcessInfo(info);
}
test "Surface: 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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelectionIsNull(
3.0, 3, // click
3.1, 3, // drag
false, // regular selection
);
// empty selection (single cell on only right half)
try testMouseSelectionIsNull(
3.8, 3, // click
3.9, 3, // drag
false, // regular selection
);
// empty selection (between two cells, not crossing threshold)
try testMouseSelectionIsNull(
3.9, 3, // click
4.0, 3, // drag
false, // regular selection
);
// -- RTL
// single cell selection
try testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelectionIsNull(
3.1, 3, // click
3.0, 3, // drag
false, // regular selection
);
// empty selection (single cell on only right half)
try testMouseSelectionIsNull(
3.9, 3, // click
3.8, 3, // drag
false, // regular selection
);
// empty selection (between two cells, not crossing threshold)
try testMouseSelectionIsNull(
4.0, 3, // click
3.9, 3, // drag
false, // regular selection
);
// -- Wrapping
// LTR, wrap excluded cells
try testMouseSelection(
9.9, 2, // click
0.0, 4, // drag
0, 3, // expected start
9, 3, // expected end
false, // regular selection
);
// RTL, wrap excluded cells
try testMouseSelection(
0.0, 4, // click
9.9, 2, // drag
9, 3, // expected start
0, 3, // expected end
false, // regular selection
);
}
test "Surface: rectangle 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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelectionIsNull(
3.0, 2, // click
3.1, 4, // drag
true, //rectangle selection
);
// empty selection (single column on only right half)
try testMouseSelectionIsNull(
3.8, 2, // click
3.9, 4, // drag
true, //rectangle selection
);
// empty selection (between two columns, not crossing threshold)
try testMouseSelectionIsNull(
3.9, 2, // click
4.0, 4, // drag
true, //rectangle selection
);
// -- RTL
// single column selection
try testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelection(
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 testMouseSelectionIsNull(
3.1, 2, // click
3.0, 4, // drag
true, //rectangle selection
);
// empty selection (single column on only right half)
try testMouseSelectionIsNull(
3.9, 2, // click
3.8, 4, // drag
true, //rectangle selection
);
// empty selection (between two columns, not crossing threshold)
try testMouseSelectionIsNull(
4.0, 2, // click
3.9, 4, // drag
true, //rectangle selection
);
// -- Wrapping
// LTR, do not wrap
try testMouseSelection(
9.9, 2, // click
0.0, 4, // drag
9, 2, // expected start
0, 4, // expected end
true, //rectangle selection
);
// RTL, do not wrap
try testMouseSelection(
0.0, 4, // click
9.9, 2, // drag
0, 4, // expected start
9, 2, // expected end
true, //rectangle selection
);
}

View File

@@ -9,7 +9,9 @@ const testing = std.testing;
const Allocator = std.mem.Allocator;
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");
/// The tracked pin of the initial left click along with the screen
@@ -30,6 +32,27 @@ left_click_time: ?std.time.Instant,
left_click_xpos: f64,
left_click_ypos: f64,
/// 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 scroll the screen the desired direction a certain
/// amount. The timer and amount is up to the caller but reasonable
/// defaults are approximately one row every 15 milliseconds.
///
/// This is used to implement selection above/below the viewport that
/// wants to drag the viewport.
pub const Autoscroll = enum { none, up, down };
/// 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,
@@ -38,6 +61,7 @@ pub const init: SelectionGesture = .{
.left_click_screen_generation = 0,
.left_click_xpos = 0,
.left_click_ypos = 0,
.left_drag_autoscroll = .none,
};
pub fn deinit(self: *SelectionGesture, t: *Terminal) void {
@@ -53,9 +77,24 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void {
pub fn reset(self: *SelectionGesture, t: *Terminal) void {
self.left_click_count = 0;
self.left_click_time = null;
self.left_drag_autoscroll = .none;
self.untrackPin(t);
}
/// Return the tracked left-click pin only if it still belongs to the 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.
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
@@ -101,6 +140,78 @@ pub fn press(
try self.pressInitial(t, 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,
/// 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.
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;
// 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;
return dragSelection(
click_pin.*,
d.pin,
@intFromFloat(@max(0, self.left_click_xpos)),
@intFromFloat(@max(0, d.xpos)),
d.rectangle,
d.geometry,
);
}
fn pressInitial(
self: *SelectionGesture,
t: *Terminal,
@@ -125,6 +236,7 @@ fn pressInitial(
self.left_click_xpos = p.xpos;
self.left_click_ypos = p.ypos;
self.left_click_time = p.time;
self.left_drag_autoscroll = .none;
}
fn pressRepeat(
@@ -166,12 +278,155 @@ fn pressRepeat(
}
self.left_click_time = time;
self.left_drag_autoscroll = .none;
self.left_click_count = @min(
self.left_click_count + 1,
3, // We only support triple clicks max
);
}
/// 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,
);
}
fn untrackPin(self: *SelectionGesture, t: *Terminal) void {
// Can't untrack unless we have a pin.
const pin = self.left_click_pin orelse return;
@@ -200,6 +455,435 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press {
};
}
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,
.geometry = .{
.columns = 5,
.cell_width = 10,
.padding_left = 0,
.screen_height = 100,
},
};
}
/// 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);
@@ -216,6 +900,33 @@ test "SelectionGesture press records initial click" {
try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos);
}
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.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 repeat increments click count" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);