terminal: SelectionGesture deep press

This commit is contained in:
Mitchell Hashimoto
2026-05-26 21:33:10 -07:00
parent df98b6d983
commit f5f9d32d0a
2 changed files with 96 additions and 11 deletions

View File

@@ -4456,9 +4456,11 @@ pub fn mousePressureCallback(
// Update our pressure stage.
self.mouse.pressure_stage = stage;
// If our left mouse button is pressed and we're entering a deep
// click then we want to start a selection. We treat this as a
// word selection since that is typical macOS behavior.
// A deep press is pressure-sensitive pointer input, such as macOS force
// click / deep click on a trackpad, that occurs while the left mouse
// button is already down. Treat it as the platform text-selection
// affordance: select the pressed word, then consume the active gesture so
// further cursor motion doesn't drag the selection.
const left_idx = @intFromEnum(input.MouseButton.left);
if (self.mouse.click_state[left_idx] == .press and
stage == .deep)
@@ -4466,14 +4468,21 @@ pub fn mousePressureCallback(
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
// This should always be set in this state but we don't want
// to handle state inconsistency here.
const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select;
const sel = self.io.terminal.screens.active.selectWord(
pin.*,
self.config.selection_word_chars,
) orelse break :select;
try self.io.terminal.screens.active.select(sel);
const sel = self.mouse.selection_gesture.deepPress(
self.renderer_state.terminal,
.{ .word_boundary_codepoints = self.config.selection_word_chars },
);
// Deep press consumes the active drag gesture, so stop any pending
// selection autoscroll timer that may have been started by the drag.
if (self.selection_scroll_active) {
self.queueIo(
.{ .selection_scroll = false },
.locked,
);
}
try self.io.terminal.screens.active.select(sel orelse break :select);
try self.queueRender();
}
}

View File

@@ -243,6 +243,10 @@ pub fn drag(
/// 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 position.
///
/// 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.
pub fn autoscrollTick(
self: *SelectionGesture,
t: *Terminal,
@@ -271,6 +275,46 @@ pub fn autoscrollTick(
return self.drag(t, d);
}
/// 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.
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_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
@@ -1193,6 +1237,38 @@ test "SelectionGesture autoscroll tick stops with invalidated click" {
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);