diff --git a/src/Surface.zig b/src/Surface.zig index 248cccea1..f9945efb8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -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 { diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 73904844c..6adad5b4a 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -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);