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) + } +}