terminal: add configurable behaviors based on click count

pull/12830/head
Mitchell Hashimoto 2026-05-27 06:29:43 -07:00
parent 82a73f2bf1
commit 7d4d1e5819
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
2 changed files with 152 additions and 28 deletions

View File

@ -3961,6 +3961,11 @@ pub fn mouseButtonCallback(
.max_distance = @floatFromInt(self.size.cell.width), .max_distance = @floatFromInt(self.size.cell.width),
.repeat_interval = self.config.mouse_interval, .repeat_interval = self.config.mouse_interval,
.word_boundary_codepoints = self.config.selection_word_chars, .word_boundary_codepoints = self.config.selection_word_chars,
.behaviors = &.{
.cell,
.word,
if (mods.ctrlOrSuper()) .output else .line,
},
}); });
// The gesture owns the standard single/double/triple-click selection // The gesture owns the standard single/double/triple-click selection
@ -3984,12 +3989,7 @@ pub fn mouseButtonCallback(
} }
}, },
// Cmd/Ctrl triple-click selects semantic command output instead of 3 => {},
// the standard line selection returned by the gesture.
3 => {
if (mods.ctrlOrSuper()) press_selection =
self.io.terminal.screens.active.selectOutput(pin);
},
// We should be bounded by 1 to 3 // We should be bounded by 1 to 3
else => unreachable, else => unreachable,

View File

@ -20,12 +20,12 @@
/// ///
/// Double- and triple-click gestures use the same event flow. Repeated presses /// Double- and triple-click gestures use the same event flow. Repeated presses
/// inside `Press.repeat_interval` and within `Press.max_distance` increment the /// inside `Press.repeat_interval` and within `Press.max_distance` increment the
/// internal click count up to three. A single press returns null to clear any /// internal click count up to three. `Press.behaviors` maps single-, double-,
/// existing selection, a double-click returns a word selection, and a /// and triple-clicks to behavior. By default, a single press returns null to
/// triple-click returns a line selection. A drag after a double-click expands by /// clear any existing selection, a double-click returns a word selection, and a
/// word; a drag after a triple-click expands by line. A new press that is too /// triple-click returns a line selection. Drags use the behavior selected by the
/// late, too far away, or on another active screen starts a new single-click /// corresponding press. A new press that is too late, too far away, or on
/// gesture. /// another active screen starts a new single-click gesture.
/// ///
/// # Resetting and lifetime /// # Resetting and lifetime
/// ///
@ -89,6 +89,9 @@ left_click_screen_generation: usize,
left_click_count: u3, left_click_count: u3,
left_click_time: ?std.time.Instant, 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, /// 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 /// these will point to different cells, but the xpos/ypos will stay
/// stable during scrolling relative to the surface. /// stable during scrolling relative to the surface.
@ -116,6 +119,28 @@ left_drag_autoscroll: Autoscroll,
/// wants to drag the viewport. /// wants to drag the viewport.
pub const Autoscroll = enum { none, up, down }; pub const Autoscroll = enum { none, up, down };
/// The selection behavior for a click and subsequent drag.
pub const Behavior = enum {
/// 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 /// Distance from the top or bottom surface edge, in pixels, where dragging
/// should request autoscroll. This preserves the historical 1px buffer used /// should request autoscroll. This preserves the historical 1px buffer used
/// so fullscreen-edge drags can still trigger autoscroll. /// so fullscreen-edge drags can still trigger autoscroll.
@ -125,6 +150,7 @@ pub const init: SelectionGesture = .{
.left_click_pin = null, .left_click_pin = null,
.left_click_count = 0, .left_click_count = 0,
.left_click_time = null, .left_click_time = null,
.left_click_behavior = .cell,
.left_click_screen = .primary, .left_click_screen = .primary,
.left_click_screen_generation = 0, .left_click_screen_generation = 0,
.left_click_xpos = 0, .left_click_xpos = 0,
@ -157,6 +183,7 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void {
pub fn reset(self: *SelectionGesture, t: *Terminal) void { pub fn reset(self: *SelectionGesture, t: *Terminal) void {
self.left_click_count = 0; self.left_click_count = 0;
self.left_click_time = null; self.left_click_time = null;
self.left_click_behavior = .cell;
self.left_click_dragged = false; self.left_click_dragged = false;
self.left_drag_autoscroll = .none; self.left_drag_autoscroll = .none;
self.untrackPin(t); self.untrackPin(t);
@ -213,6 +240,9 @@ pub const Press = struct {
/// The codepoints that delimit words for double-click selection. /// The codepoints that delimit words for double-click selection.
word_boundary_codepoints: []const u21, 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. /// Record a press event and return the standard selection for this click.
@ -227,11 +257,11 @@ pub const Press = struct {
/// ///
/// Examples: /// Examples:
/// ///
/// * first press: `left_click_count == 1`, returns null to clear selection; /// * first press: `left_click_count == 1`, defaults to cell behavior;
/// * second nearby press within the repeat interval: `left_click_count == 2`, /// * second nearby press within the repeat interval: `left_click_count == 2`,
/// returns a word selection and later drags select by word; /// defaults to word behavior;
/// * third nearby press within the repeat interval: `left_click_count == 3`, /// * third nearby press within the repeat interval: `left_click_count == 3`,
/// returns a line selection and later drags select by line; /// defaults to line behavior;
/// * press after the interval, too far away, or after a screen generation /// * press after the interval, too far away, or after a screen generation
/// change: starts over at `left_click_count == 1` and returns null. /// change: starts over at `left_click_count == 1` and returns null.
pub fn press( pub fn press(
@ -336,10 +366,8 @@ pub fn drag(
else else
.none; .none;
return switch (self.left_click_count) { return switch (self.left_click_behavior) {
0 => unreachable, // handled above .cell => dragSelection(
1 => dragSelection(
click_pin.*, click_pin.*,
d.pin, d.pin,
@intFromFloat(@max(0, self.left_click_xpos)), @intFromFloat(@max(0, self.left_click_xpos)),
@ -348,19 +376,24 @@ pub fn drag(
d.geometry, d.geometry,
), ),
2 => dragSelectionWord( .word => dragSelectionWord(
t.screens.active, t.screens.active,
click_pin.*, click_pin.*,
d.pin, d.pin,
d.word_boundary_codepoints, d.word_boundary_codepoints,
), ),
3 => dragSelectionLine( .line => dragSelectionLine(
t.screens.active,
click_pin.*,
d.pin,
),
.output => dragSelectionOutput(
t.screens.active, t.screens.active,
click_pin.*, click_pin.*,
d.pin, d.pin,
), ),
else => unreachable,
}; };
} }
@ -445,6 +478,7 @@ pub fn deepPress(
self.left_click_count = 0; self.left_click_count = 0;
self.left_click_time = null; self.left_click_time = null;
self.left_click_behavior = .cell;
self.left_click_dragged = true; self.left_click_dragged = true;
self.left_drag_autoscroll = .none; self.left_drag_autoscroll = .none;
self.untrackPin(t); self.untrackPin(t);
@ -512,6 +546,7 @@ fn pressInitial(
} }
errdefer comptime unreachable; errdefer comptime unreachable;
self.left_click_count = 1; self.left_click_count = 1;
self.left_click_behavior = p.behaviors[0];
self.left_click_xpos = p.xpos; self.left_click_xpos = p.xpos;
self.left_click_ypos = p.ypos; self.left_click_ypos = p.ypos;
self.left_click_time = p.time; self.left_click_time = p.time;
@ -526,6 +561,7 @@ fn pressRepeat(
) error{PressRequiresReset}!void { ) error{PressRequiresReset}!void {
errdefer { errdefer {
self.left_click_count = 0; self.left_click_count = 0;
self.left_click_behavior = .cell;
self.untrackPin(t); self.untrackPin(t);
} }
@ -564,6 +600,7 @@ fn pressRepeat(
self.left_click_count + 1, self.left_click_count + 1,
3, // We only support triple clicks max 3, // We only support triple clicks max
); );
self.left_click_behavior = p.behaviors[self.left_click_count - 1];
} }
fn pressSelection( fn pressSelection(
@ -571,12 +608,11 @@ fn pressSelection(
screen: *Screen, screen: *Screen,
p: Press, p: Press,
) ?Selection { ) ?Selection {
return switch (self.left_click_count) { return switch (self.left_click_behavior) {
0 => unreachable, .cell => null,
1 => null, .word => screen.selectWord(p.pin, p.word_boundary_codepoints),
2 => screen.selectWord(p.pin, p.word_boundary_codepoints), .line => screen.selectLine(.{ .pin = p.pin }),
3 => screen.selectLine(.{ .pin = p.pin }), .output => screen.selectOutput(p.pin),
else => unreachable,
}; };
} }
@ -784,6 +820,26 @@ fn dragSelectionLine(
return sel; 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 { fn untrackPin(self: *SelectionGesture, t: *Terminal) void {
// Can't untrack unless we have a pin. // Can't untrack unless we have a pin.
const pin = self.left_click_pin orelse return; const pin = self.left_click_pin orelse return;
@ -1294,6 +1350,74 @@ test "SelectionGesture press returns standard click selections" {
), (try gesture.press(&t, event)).?); ), (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" { test "SelectionGesture drag returns selection and records autoscroll" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator); defer t.deinit(testing.allocator);