From 543f214545d210965d10efab681220d5d27be2d3 Mon Sep 17 00:00:00 2001 From: matt chow <64496321@qq.com> Date: Mon, 27 Jul 2026 08:41:18 +0800 Subject: [PATCH] fix: keep drag selection alive over the gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mouseDragged` clamped the pointer to `max(0, ...)`, but the layout manager can only resolve offsets at or right of `edgeInsets.left` — the strip a gutter is drawn over. Any position further left made `textOffsetAtPoint` return nil, which tripped the guard and returned early, so the whole drag event was dropped: the selection stopped updating (and autoscroll stopped firing) for as long as the pointer sat over the gutter. Dragging up and to the left therefore stranded the selection at the last sample taken inside the text area, typically a few characters into the top line. This is the residue of CodeEditApp/CodeEditSourceEditor#316 and #100, which fixed the right edge but not the left. Clamp into the inset region instead, so positions over the gutter map to the start of a line. Also anchor the drag on mouse down rather than on the first drag event, which otherwise starts the selection wherever the pointer had already traveled to. Co-Authored-By: Claude Opus 5 (1M context) --- .../TextView/TextView+Mouse.swift | 30 ++++- .../TextViewMouseTests.swift | 107 ++++++++++++++++++ 2 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 Tests/CodeEditTextViewTests/TextViewMouseTests.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 0609665f..07baae83 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -34,6 +34,11 @@ extension TextView { break } + // Anchor the drag at the press location. Waiting for the first drag event to set this anchors the selection + // wherever the pointer had already traveled to by then, which reads as the selection starting a character or + // two away from where the user clicked. + mouseDragAnchor = clampToTextArea(convert(event.locationInWindow, from: nil)) + setUpMouseAutoscrollTimer() } @@ -119,11 +124,7 @@ extension TextView { // We receive global events because our view received the drag event, but we need to clamp the potentially // out-of-bounds positions to a position our layout manager can deal with. - let locationInWindow = convert(event.locationInWindow, from: nil) - let locationInView = CGPoint( - x: max(0.0, min(locationInWindow.x, frame.width)), - y: max(0.0, min(locationInWindow.y, frame.height)) - ) + let locationInView = clampToTextArea(convert(event.locationInWindow, from: nil)) if mouseDragAnchor == nil { mouseDragAnchor = locationInView @@ -147,6 +148,25 @@ extension TextView { } } + /// Clamps a point to the region ``TextLayoutManager/textOffsetAtPoint(_:)`` can resolve into a text offset. + /// + /// Drag events are delivered in global coordinates, so the point can lie well outside the view. The horizontal + /// bounds also have to respect ``TextLayoutManager/edgeInsets``: text is laid out inset from the view's edges, and + /// any position left of the leading inset — the strip a gutter is drawn over, for instance — resolves to `nil`. + /// + /// A `nil` offset mid-drag strands the selection at its last resolvable value, so the selection stops responding + /// entirely while the pointer sits over the gutter. Clamping into the inset region instead maps those positions to + /// the start of a line, which is what AppKit does. + func clampToTextArea(_ point: CGPoint) -> CGPoint { + let insets = layoutManager.edgeInsets + let minX = insets.left + let maxX = max(minX, frame.width - insets.right) + return CGPoint( + x: min(max(point.x, minX), maxX), + y: min(max(point.y, 0.0), frame.height) + ) + } + /// Extends the current selection to the offset. Only used when the user shift-clicks a location in the document. /// /// If the offset is within the selection, trims the selection from the nearest edge (start or end) towards the diff --git a/Tests/CodeEditTextViewTests/TextViewMouseTests.swift b/Tests/CodeEditTextViewTests/TextViewMouseTests.swift new file mode 100644 index 00000000..51890bf0 --- /dev/null +++ b/Tests/CodeEditTextViewTests/TextViewMouseTests.swift @@ -0,0 +1,107 @@ +import Testing +import AppKit +@testable import CodeEditTextView + +@Suite +@MainActor +struct TextViewMouseTests { + let textView: TextView + + init() { + textView = TextView(string: "Lorem Ipsum\nDolor Sit Amet\nConsectetur") + } + + /// `layout()` resizes the frame to fit its content (there's no enclosing scroll view to take the size from), so + /// the test frame has to be applied afterwards for the view to be genuinely wider than its insets. + private func layout(left: CGFloat, right: CGFloat, width: CGFloat = 500) { + textView.edgeInsets = HorizontalEdgeInsets(left: left, right: right) + textView.layout() + textView.frame = NSRect(x: 0, y: 0, width: width, height: 300) + } + + /// A drag that leaves the leading inset — the strip a gutter is drawn over — must still resolve to a text + /// offset. Otherwise the layout manager returns `nil` and the in-progress selection stops updating. + @Test + func pointOverLeadingInsetResolvesToTextOffset() { + layout(left: 60, right: 0) + + let overGutter = CGPoint(x: 12, y: 2) + #expect(textView.layoutManager.textOffsetAtPoint(overGutter) == nil, "Precondition: raw point is unresolvable") + + let clamped = textView.clampToTextArea(overGutter) + #expect(clamped.x == 60) + #expect(textView.layoutManager.textOffsetAtPoint(clamped) == 0, "Should resolve to the start of line 1") + } + + /// Points left of the view entirely — the pointer having been dragged out of the window — clamp the same way. + @Test + func pointLeftOfViewResolvesToLineStart() { + layout(left: 60, right: 0) + + let clamped = textView.clampToTextArea(CGPoint(x: -400, y: 2)) + #expect(clamped.x == 60) + #expect(textView.layoutManager.textOffsetAtPoint(clamped) == 0) + } + + @Test + func pointRightOfViewClampsInsideTrailingInset() { + layout(left: 60, right: 40) + #expect(textView.frame.width == 500, "Precondition: the view is wider than its insets") + + let clamped = textView.clampToTextArea(CGPoint(x: 9_000, y: 2)) + #expect(clamped.x == 460) + #expect(textView.layoutManager.textOffsetAtPoint(clamped) != nil) + } + + @Test + func pointInsideTextAreaIsUnchanged() { + layout(left: 60, right: 40) + + let inside = CGPoint(x: 200, y: 2) + #expect(textView.clampToTextArea(inside) == inside) + } + + @Test + func verticalPositionsClampToTheView() { + layout(left: 60, right: 0) + + #expect(textView.clampToTextArea(CGPoint(x: 100, y: -500)).y == 0) + #expect(textView.clampToTextArea(CGPoint(x: 100, y: 9_000)).y == textView.frame.height) + } + + /// With insets wider than the view, the clamp must not produce an inverted range. + @Test + func degenerateInsetsDoNotInvert() { + layout(left: 60, right: 40, width: 50) + + #expect(textView.clampToTextArea(CGPoint(x: 10, y: 2)).x == 60) + } + + /// The drag anchor is set on mouse down, not on the first drag event, so a selection starts exactly where the + /// user pressed rather than wherever the pointer had traveled to by the time the first drag event arrived. + @Test + func mouseDownSetsDragAnchor() throws { + layout(left: 60, right: 0) + #expect(textView.mouseDragAnchor == nil) + + let event = try #require( + NSEvent.mouseEvent( + with: .leftMouseDown, + location: NSPoint(x: 100, y: 2), + modifierFlags: [], + timestamp: 0, + windowNumber: 0, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: 1 + ) + ) + textView.mouseDown(with: event) + + // The view has no window, so the window→view conversion isn't meaningful enough to assert an exact point; + // what matters is that the anchor exists before any drag event arrives, and that it's resolvable. + let anchor = try #require(textView.mouseDragAnchor) + #expect(textView.layoutManager.textOffsetAtPoint(anchor) != nil) + } +}