From 141c7d44d2d10621d6b8f014c6a1ec3e416f14ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 26 May 2026 21:13:46 -0700 Subject: [PATCH] SelectionGesture: release event --- src/Surface.zig | 40 ++++++++++++++++---- src/terminal/SelectionGesture.zig | 62 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f9945efb8..8abbcecea 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3815,6 +3815,30 @@ pub fn mouseButtonCallback( self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); + // The selection gesture tracks whether a press became a drag by + // comparing the release cell to the original press cell. Resolve the + // release position and pin before notifying the gesture so later + // release handling can query that state. + const release_pos: ?apprt.CursorPos = self.rt_surface.getCursorPos() catch |err| pos: { + log.warn("error reading cursor position for mouse release err={}", .{err}); + break :pos null; + }; + + // If we can't map the release position to a cell, pass null so the + // gesture can conservatively treat the release as having moved away + // from the pressed cell. + const release_pin: ?terminal.Pin = if (release_pos) |pos| pin: { + const release_vp = self.posToViewport(pos.x, pos.y); + break :pin self.io.terminal.screens.active.pages.pin(.{ .viewport = .{ + .x = release_vp.x, + .y = release_vp.y, + } }); + } else null; + self.mouse.selection_gesture.release( + self.renderer_state.terminal, + .{ .pin = release_pin }, + ); + // Stop selection scrolling when releasing the left mouse button // but only when selection scrolling is active. if (self.selection_scroll_active) { @@ -3823,7 +3847,6 @@ pub fn mouseButtonCallback( .locked, ); } - self.mouse.selection_gesture.left_drag_autoscroll = .none; // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard @@ -3842,10 +3865,10 @@ pub fn mouseButtonCallback( // Handle link clicking. We want to do this before we do mouse // reporting or any other mouse handling because a successfully // clicked link will swallow the event. - if (self.mouse.over_link) { + if (self.mouse.over_link and !self.mouse.selection_gesture.left_click_dragged) { // We are holding the renderer lock, but this should just be // a cached value. - const pos = try self.rt_surface.getCursorPos(); + const pos = release_pos orelse try self.rt_surface.getCursorPos(); if (self.processLinks(pos)) |processed| { if (processed) return true; } else |err| { @@ -4139,11 +4162,12 @@ fn maybePromptClick(self: *Surface) !bool { // prompt clicks because we can't move if we're not in a prompt! if (!t.cursorIsAtPrompt()) return false; - // If we have a selection currently, then releasing the mouse - // completes the selection and we don't do prompt moving. I don't - // love this logic, I think it should be generalized to "if the - // mouse release was on a different cell than the mouse press" but - // our mouse state at the time of writing this doesn't support that. + // If the left click moved away from its pressed cell then releasing the + // mouse completes the drag gesture and we don't do prompt moving. + if (self.mouse.selection_gesture.left_click_dragged) return false; + + // If we have a selection currently, then releasing the mouse completes + // the selection and we don't do prompt moving. if (screen.selection != null) return false; // Get the pin for our mouse click. diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 6adad5b4a..4e7db013b 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -32,6 +32,12 @@ left_click_time: ?std.time.Instant, left_click_xpos: f64, left_click_ypos: f64, +/// True once the active left-click gesture has moved away from the initially +/// pressed cell. This is reset on every press that starts or continues a +/// multi-click sequence, and is left available for callers to inspect while +/// handling the corresponding release. +left_click_dragged: bool, + /// The current autoscroll state for the active left-click drag gesture. left_drag_autoscroll: Autoscroll, @@ -61,6 +67,7 @@ pub const init: SelectionGesture = .{ .left_click_screen_generation = 0, .left_click_xpos = 0, .left_click_ypos = 0, + .left_click_dragged = false, .left_drag_autoscroll = .none, }; @@ -77,6 +84,7 @@ pub fn deinit(self: *SelectionGesture, t: *Terminal) void { pub fn reset(self: *SelectionGesture, t: *Terminal) void { self.left_click_count = 0; self.left_click_time = null; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; self.untrackPin(t); } @@ -193,6 +201,7 @@ pub fn drag( // screen changed out from under us then we aren't actually // clicking anymore. const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null; + if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true; // 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. @@ -232,6 +241,34 @@ pub fn drag( }; } +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 + /// not tracked. + pin: ?Pin, +}; + +/// Record a release event for the active left-click gesture. +pub fn release( + self: *SelectionGesture, + t: *Terminal, + r: Release, +) void { + if (self.left_click_count == 0) { + assert(self.left_drag_autoscroll == .none); + return; + } + + if (r.pin) |release_pin| { + if (self.validatedLeftClickPin(&t.screens)) |click_pin| { + if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true; + } + } else { + self.left_click_dragged = true; + } + self.left_drag_autoscroll = .none; +} + fn pressInitial( self: *SelectionGesture, t: *Terminal, @@ -256,6 +293,7 @@ fn pressInitial( self.left_click_xpos = p.xpos; self.left_click_ypos = p.ypos; self.left_click_time = p.time; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; } @@ -298,6 +336,7 @@ fn pressRepeat( } self.left_click_time = time; + self.left_click_dragged = false; self.left_drag_autoscroll = .none; self.left_click_count = @min( self.left_click_count + 1, @@ -988,6 +1027,7 @@ test "SelectionGesture press records initial click" { try testing.expectEqual(time, gesture.left_click_time.?); try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos); try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos); + try testing.expectEqual(false, gesture.left_click_dragged); } test "SelectionGesture drag returns selection and records autoscroll" { @@ -1003,6 +1043,7 @@ test "SelectionGesture drag returns selection and records autoscroll" { const sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?; try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); try testing.expectEqualDeep(Selection.init( t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?, @@ -1017,6 +1058,27 @@ test "SelectionGesture drag returns selection and records autoscroll" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture release clears autoscroll and records drag" { + 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 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)); + try testing.expectEqual(.up, gesture.left_drag_autoscroll); + try testing.expectEqual(false, gesture.left_click_dragged); + + gesture.release(&t, .{ + .pin = testPin(&t, 2, 1), + }); + try testing.expectEqual(.none, gesture.left_drag_autoscroll); + try testing.expectEqual(true, gesture.left_click_dragged); +} + test "SelectionGesture drag without press returns null" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator);