terminal: SelectionGesture handles word/line drag

This commit is contained in:
Mitchell Hashimoto
2026-05-26 20:58:14 -07:00
parent c00cdd886b
commit 229f4c1f4f
2 changed files with 278 additions and 96 deletions

View File

@@ -1217,6 +1217,7 @@ fn selectionScrollTick(self: *Surface) !void {
.xpos = pos.x,
.ypos = pos.y,
.rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
.word_boundary_codepoints = self.config.selection_word_chars,
.geometry = .{
.columns = @intCast(self.size.grid().columns),
.cell_width = self.size.cell.width,
@@ -4625,11 +4626,13 @@ pub fn cursorPosCallback(
return;
};
// Perform our drag behavior in our gesture handler.
const drag_selection = self.mouse.selection_gesture.drag(t, .{
.pin = pin,
.xpos = pos.x,
.ypos = pos.y,
.rectangle = SurfaceMouse.isRectangleSelectState(self.mouse.mods),
.word_boundary_codepoints = self.config.selection_word_chars,
.geometry = .{
.columns = @intCast(self.size.grid().columns),
.cell_width = self.size.cell.width,
@@ -4638,6 +4641,7 @@ pub fn cursorPosCallback(
},
});
// Update our autoscroll timer based on the gesture state
switch (self.mouse.selection_gesture.left_drag_autoscroll) {
.none => if (self.selection_scroll_active) {
self.queueIo(
@@ -4653,95 +4657,11 @@ pub fn cursorPosCallback(
},
}
// Handle dragging depending on click count
switch (self.mouse.selection_gesture.left_click_count) {
1 => try self.io.terminal.screens.active.select(drag_selection),
2 => try self.dragLeftClickDouble(pin),
3 => try self.dragLeftClickTriple(pin),
0 => unreachable, // handled above
else => unreachable,
}
return;
// Update our selection based on the gesture state
try self.io.terminal.screens.active.select(drag_selection);
}
}
/// Double-click dragging moves the selection one "word" at a time.
fn dragLeftClickDouble(
self: *Surface,
drag_pin: terminal.Pin,
) !void {
const screen: *terminal.Screen = self.io.terminal.screens.active;
const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*;
// Get the word closest to our starting click.
const word_start = screen.selectWordBetween(
click_pin,
drag_pin,
self.config.selection_word_chars,
) orelse {
try self.setSelection(null);
return;
};
// Get the word closest to our current point.
const word_current = screen.selectWordBetween(
drag_pin,
click_pin,
self.config.selection_word_chars,
) orelse {
try self.setSelection(null);
return;
};
// If our current mouse position is before the starting position,
// then the selection start is the word nearest our current position.
if (drag_pin.before(click_pin)) {
try self.io.terminal.screens.active.select(.init(
word_current.start(),
word_start.end(),
false,
));
} else {
try self.io.terminal.screens.active.select(.init(
word_start.start(),
word_current.end(),
false,
));
}
}
/// Triple-click dragging moves the selection one "line" at a time.
fn dragLeftClickTriple(
self: *Surface,
drag_pin: terminal.Pin,
) !void {
const screen: *terminal.Screen = self.io.terminal.screens.active;
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.*;
};
// 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;
// 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;
if (drag_pin.before(click_pin)) {
sel.startPtr().* = line.start();
} else {
sel.endPtr().* = line.end();
}
try self.io.terminal.screens.active.select(sel);
}
/// Call to notify Ghostty that the color scheme for the terminal has
/// changed.
pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void {

View File

@@ -153,6 +153,9 @@ pub const Drag = struct {
/// 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,
@@ -189,8 +192,7 @@ pub fn drag(
// 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;
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.
@@ -202,14 +204,32 @@ pub fn drag(
else
.none;
return dragSelection(
click_pin.*,
d.pin,
@intFromFloat(@max(0, self.left_click_xpos)),
@intFromFloat(@max(0, d.xpos)),
d.rectangle,
d.geometry,
);
return switch (self.left_click_count) {
0 => unreachable, // handled above
1 => dragSelection(
click_pin.*,
d.pin,
@intFromFloat(@max(0, self.left_click_xpos)),
@intFromFloat(@max(0, d.xpos)),
d.rectangle,
d.geometry,
),
2 => dragSelectionWord(
t.screens.active,
click_pin.*,
d.pin,
d.word_boundary_codepoints,
),
3 => dragSelectionLine(
t.screens.active,
click_pin.*,
d.pin,
),
else => unreachable,
};
}
fn pressInitial(
@@ -427,6 +447,68 @@ fn dragSelection(
);
}
/// 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;
}
fn untrackPin(self: *SelectionGesture, t: *Terminal) void {
// Can't untrack unless we have a pin.
const pin = self.left_click_pin orelse return;
@@ -464,6 +546,7 @@ fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag {
.xpos = xpos,
.ypos = ypos,
.rectangle = false,
.word_boundary_codepoints = &.{},
.geometry = .{
.columns = 5,
.cell_width = 10,
@@ -473,6 +556,13 @@ fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag {
};
}
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
@@ -927,6 +1017,178 @@ test "SelectionGesture drag returns selection and records autoscroll" {
try testing.expectEqual(.down, gesture.left_drag_autoscroll);
}
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 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);