terminal: SelectionGesture press returns standard behaviors

This commit is contained in:
Mitchell Hashimoto
2026-05-27 06:23:30 -07:00
parent 9b00bb436a
commit 82a73f2bf1
2 changed files with 135 additions and 89 deletions

View File

@@ -3953,68 +3953,60 @@ pub fn mouseButtonCallback(
log.err("error reading time, mouse multi-click won't work err={}", .{err});
break :time null;
};
try self.mouse.selection_gesture.press(t, .{
var press_selection = try self.mouse.selection_gesture.press(t, .{
.time = time,
.pin = pin,
.xpos = pos.x,
.ypos = pos.y,
.max_distance = @floatFromInt(self.size.cell.width),
.repeat_interval = self.config.mouse_interval,
.word_boundary_codepoints = self.config.selection_word_chars,
});
// In all cases below, we set the selection directly rather than use
// `setSelection` because we want to avoid copying the selection
// to the selection clipboard. For left mouse clicks we only set
// the clipboard on release.
// The gesture owns the standard single/double/triple-click selection
// behavior. Surface keeps terminal-surface-specific overrides here.
switch (self.mouse.selection_gesture.left_click_count) {
// Single click
1 => {
// If we have a selection, clear it. This always happens.
if (self.io.terminal.screens.active.selection != null) {
try self.io.terminal.screens.active.select(null);
try self.queueRender();
}
},
1 => {},
// Double click, select the word under our mouse.
// First try to detect if we're clicking on a URL to select the entire URL.
// Double click on a URL selects the entire URL instead of the
// standard word selection returned by the gesture.
2 => {
const sel_ = sel: {
// Try link detection without requiring modifier keys
if (self.linkAtPin(
pin,
null,
)) |result_| {
if (result_) |result| {
break :sel result.selection;
}
} else |_| {
// Ignore any errors, likely regex errors.
// Try link detection without requiring modifier keys.
if (self.linkAtPin(
pin,
null,
)) |result_| {
if (result_) |result| {
press_selection = result.selection;
}
break :sel self.io.terminal.screens.active.selectWord(pin, self.config.selection_word_chars);
};
if (sel_) |sel| {
try self.io.terminal.screens.active.select(sel);
try self.queueRender();
} else |_| {
// Ignore any errors, likely regex errors.
}
},
// Triple click, select the line under our mouse
// Cmd/Ctrl triple-click selects semantic command output instead of
// the standard line selection returned by the gesture.
3 => {
const sel_ = if (mods.ctrlOrSuper())
self.io.terminal.screens.active.selectOutput(pin)
else
self.io.terminal.screens.active.selectLine(.{ .pin = pin });
if (sel_) |sel| {
try self.io.terminal.screens.active.select(sel);
try self.queueRender();
}
if (mods.ctrlOrSuper()) press_selection =
self.io.terminal.screens.active.selectOutput(pin);
},
// We should be bounded by 1 to 3
else => unreachable,
}
// We set the selection directly rather than use `setSelection` because
// we want to avoid copying the selection to the selection clipboard.
// For left mouse clicks we only set the clipboard on release.
if (press_selection) |selection| {
try self.io.terminal.screens.active.select(selection);
try self.queueRender();
} else if (self.mouse.selection_gesture.left_click_count == 1 and
self.io.terminal.screens.active.selection != null)
{
try self.io.terminal.screens.active.select(null);
try self.queueRender();
}
}
// Middle-click paste source follows copy-on-select: when copy-on-select

View File

@@ -10,7 +10,8 @@
/// A typical single-click drag flow looks like this:
///
/// ```zig
/// try gesture.press(terminal, .{ ... });
/// const selection = try gesture.press(terminal, .{ ... });
/// try terminal.screens.active.select(selection);
/// if (gesture.drag(terminal, .{ ... })) |selection| {
/// try terminal.screens.active.select(selection);
/// }
@@ -19,9 +20,12 @@
///
/// 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. A drag after a double-click expands by word;
/// a drag after a triple-click expands by line. A new press that is too late,
/// too far away, or on another active screen starts a new single-click gesture.
/// internal click count up to three. 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. A drag after a double-click expands by
/// word; a drag after a triple-click expands by line. A new press that is too
/// late, too far away, or on another active screen starts a new single-click
/// gesture.
///
/// # Resetting and lifetime
///
@@ -206,32 +210,39 @@ pub const Press = struct {
/// 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,
};
/// Record a press event.
/// 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`, later drags select by cell;
/// * first press: `left_click_count == 1`, returns null to clear selection;
/// * second nearby press within the repeat interval: `left_click_count == 2`,
/// later drags select by word;
/// returns a word selection and later drags select by word;
/// * third nearby press within the repeat interval: `left_click_count == 3`,
/// later drags select by line;
/// returns a line selection and later drags select by line;
/// * press after the interval, too far away, or after a screen generation
/// change: starts over at `left_click_count == 1`.
/// change: starts over at `left_click_count == 1` and returns null.
pub fn press(
self: *SelectionGesture,
t: *Terminal,
p: Press,
) Allocator.Error!void {
) Allocator.Error!?Selection {
if (self.left_click_count > 0) {
if (self.pressRepeat(t, p)) {
// Successful repeat, return.
return;
// Successful repeat.
return self.pressSelection(t.screens.active, p);
} else |err| switch (err) {
error.PressRequiresReset => {},
}
@@ -240,6 +251,7 @@ pub fn press(
// 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 {
@@ -554,6 +566,20 @@ fn pressRepeat(
);
}
fn pressSelection(
self: *const SelectionGesture,
screen: *Screen,
p: Press,
) ?Selection {
return switch (self.left_click_count) {
0 => unreachable,
1 => null,
2 => screen.selectWord(p.pin, p.word_boundary_codepoints),
3 => screen.selectLine(.{ .pin = p.pin }),
else => unreachable,
};
}
/// 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(
@@ -783,6 +809,7 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press {
.ypos = @floatFromInt(y),
.max_distance = 1,
.repeat_interval = std.math.maxInt(u64),
.word_boundary_codepoints = &.{},
};
}
@@ -1231,7 +1258,7 @@ test "SelectionGesture press records initial click" {
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
try gesture.press(&t, testPress(&t, 1, 2, time));
_ = 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.?);
@@ -1240,6 +1267,33 @@ test "SelectionGesture press records initial click" {
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 drag returns selection and records autoscroll" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
@@ -1249,7 +1303,7 @@ test "SelectionGesture drag returns selection and records autoscroll" {
var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
press_event.xpos = 10;
try gesture.press(&t, press_event);
_ = 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);
@@ -1275,7 +1329,7 @@ test "SelectionGesture release clears autoscroll and records drag" {
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, try std.time.Instant.now()));
try testing.expectEqual(false, gesture.left_click_dragged);
_ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1));
@@ -1309,7 +1363,7 @@ test "SelectionGesture drag autoscroll edge boundaries" {
var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
press_event.xpos = 10;
try gesture.press(&t, press_event);
_ = try gesture.press(&t, press_event);
_ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
try testing.expectEqual(.up, gesture.left_drag_autoscroll);
@@ -1333,7 +1387,7 @@ test "SelectionGesture autoscroll tick scrolls and continues drag" {
var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
press_event.xpos = 10;
try gesture.press(&t, press_event);
_ = try gesture.press(&t, press_event);
_ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100));
try testing.expectEqual(.down, gesture.left_drag_autoscroll);
@@ -1357,7 +1411,7 @@ test "SelectionGesture autoscroll tick stops with invalidated click" {
var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
press_event.xpos = 10;
try gesture.press(&t, press_event);
_ = try gesture.press(&t, press_event);
_ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
try testing.expectEqual(.up, gesture.left_drag_autoscroll);
@@ -1381,7 +1435,7 @@ test "SelectionGesture deep press selects word and consumes drag" {
var gesture: SelectionGesture = .init;
defer gesture.deinit(&t);
try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now()));
_ = 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);
@@ -1414,7 +1468,7 @@ test "SelectionGesture drag with invalidated click returns null" {
var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
press_event.xpos = 10;
try gesture.press(&t, press_event);
_ = try gesture.press(&t, press_event);
_ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
try testing.expectEqual(.up, gesture.left_drag_autoscroll);
@@ -1438,8 +1492,8 @@ test "SelectionGesture double-click drag selects by word" {
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));
_ = try gesture.press(&t, testPress(&t, 1, 0, time));
var drag_event = testDrag(&t, 7, 0, 70, 50);
drag_event.word_boundary_codepoints = &.{ ' ' };
@@ -1461,8 +1515,8 @@ test "SelectionGesture double-click drag selects by word backwards" {
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));
_ = 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 = &.{ ' ' };
@@ -1484,8 +1538,8 @@ test "SelectionGesture double-click drag on empty cell selects nearest word" {
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));
_ = try gesture.press(&t, testPress(&t, 1, 0, time));
var drag_event = testDrag(&t, 15, 0, 150, 50);
drag_event.word_boundary_codepoints = &.{ ' ' };
@@ -1507,9 +1561,9 @@ test "SelectionGesture triple-click drag selects by line" {
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));
_ = 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)).?;
@@ -1529,9 +1583,9 @@ test "SelectionGesture triple-click drag selects by line backwards" {
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));
_ = 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)).?;
@@ -1550,8 +1604,8 @@ test "SelectionGesture repeat increments click count" {
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 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);
}
@@ -1564,7 +1618,7 @@ test "SelectionGesture repeat clamps at triple click" {
defer gesture.deinit(&t);
const time = try std.time.Instant.now();
for (0..4) |_| try gesture.press(&t, testPress(&t, 1, 1, time));
for (0..4) |_| _ = try gesture.press(&t, testPress(&t, 1, 1, time));
try testing.expectEqual(@as(u3, 3), gesture.left_click_count);
}
@@ -1576,8 +1630,8 @@ test "SelectionGesture null initial time stays single click" {
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 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);
@@ -1590,8 +1644,8 @@ test "SelectionGesture null repeat time stays single click" {
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 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);
@@ -1605,8 +1659,8 @@ test "SelectionGesture distant press resets click count" {
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 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);
@@ -1621,11 +1675,11 @@ test "SelectionGesture expired repeat resets click count" {
var event = testPress(&t, 1, 1, try std.time.Instant.now());
event.repeat_interval = 0;
try gesture.press(&t, event);
_ = 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 gesture.press(&t, event);
try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
}
@@ -1639,14 +1693,14 @@ test "SelectionGesture screen switch resets click count" {
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 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 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);
@@ -1665,11 +1719,11 @@ test "SelectionGesture removed screen resets without untracking stale pin" {
.rows = t.rows,
});
t.screens.switchTo(.alternate);
try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
_ = 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 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);
@@ -1681,7 +1735,7 @@ test "SelectionGesture deinit untracks pin" {
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 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);