From f5f9d32d0a42b55bb80599a000e63c33a25d549e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 21:33:10 -0700 Subject: [PATCH] terminal: SelectionGesture deep press --- src/Surface.zig | 31 ++++++++----- src/terminal/SelectionGesture.zig | 76 +++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fded5b137..f2c98ec0d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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(); } } diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 28663ac08..70ccaa149 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -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);