Skip to content

Commit 543f214

Browse files
BetaYaoclaude
andcommitted
fix: keep drag selection alive over the gutter
`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) <noreply@anthropic.com>
1 parent d7ac3f1 commit 543f214

2 files changed

Lines changed: 132 additions & 5 deletions

File tree

Sources/CodeEditTextView/TextView/TextView+Mouse.swift

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ extension TextView {
3434
break
3535
}
3636

37+
// Anchor the drag at the press location. Waiting for the first drag event to set this anchors the selection
38+
// wherever the pointer had already traveled to by then, which reads as the selection starting a character or
39+
// two away from where the user clicked.
40+
mouseDragAnchor = clampToTextArea(convert(event.locationInWindow, from: nil))
41+
3742
setUpMouseAutoscrollTimer()
3843
}
3944

@@ -119,11 +124,7 @@ extension TextView {
119124

120125
// We receive global events because our view received the drag event, but we need to clamp the potentially
121126
// out-of-bounds positions to a position our layout manager can deal with.
122-
let locationInWindow = convert(event.locationInWindow, from: nil)
123-
let locationInView = CGPoint(
124-
x: max(0.0, min(locationInWindow.x, frame.width)),
125-
y: max(0.0, min(locationInWindow.y, frame.height))
126-
)
127+
let locationInView = clampToTextArea(convert(event.locationInWindow, from: nil))
127128

128129
if mouseDragAnchor == nil {
129130
mouseDragAnchor = locationInView
@@ -147,6 +148,25 @@ extension TextView {
147148
}
148149
}
149150

151+
/// Clamps a point to the region ``TextLayoutManager/textOffsetAtPoint(_:)`` can resolve into a text offset.
152+
///
153+
/// Drag events are delivered in global coordinates, so the point can lie well outside the view. The horizontal
154+
/// bounds also have to respect ``TextLayoutManager/edgeInsets``: text is laid out inset from the view's edges, and
155+
/// any position left of the leading inset — the strip a gutter is drawn over, for instance — resolves to `nil`.
156+
///
157+
/// A `nil` offset mid-drag strands the selection at its last resolvable value, so the selection stops responding
158+
/// entirely while the pointer sits over the gutter. Clamping into the inset region instead maps those positions to
159+
/// the start of a line, which is what AppKit does.
160+
func clampToTextArea(_ point: CGPoint) -> CGPoint {
161+
let insets = layoutManager.edgeInsets
162+
let minX = insets.left
163+
let maxX = max(minX, frame.width - insets.right)
164+
return CGPoint(
165+
x: min(max(point.x, minX), maxX),
166+
y: min(max(point.y, 0.0), frame.height)
167+
)
168+
}
169+
150170
/// Extends the current selection to the offset. Only used when the user shift-clicks a location in the document.
151171
///
152172
/// If the offset is within the selection, trims the selection from the nearest edge (start or end) towards the
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import Testing
2+
import AppKit
3+
@testable import CodeEditTextView
4+
5+
@Suite
6+
@MainActor
7+
struct TextViewMouseTests {
8+
let textView: TextView
9+
10+
init() {
11+
textView = TextView(string: "Lorem Ipsum\nDolor Sit Amet\nConsectetur")
12+
}
13+
14+
/// `layout()` resizes the frame to fit its content (there's no enclosing scroll view to take the size from), so
15+
/// the test frame has to be applied afterwards for the view to be genuinely wider than its insets.
16+
private func layout(left: CGFloat, right: CGFloat, width: CGFloat = 500) {
17+
textView.edgeInsets = HorizontalEdgeInsets(left: left, right: right)
18+
textView.layout()
19+
textView.frame = NSRect(x: 0, y: 0, width: width, height: 300)
20+
}
21+
22+
/// A drag that leaves the leading inset — the strip a gutter is drawn over — must still resolve to a text
23+
/// offset. Otherwise the layout manager returns `nil` and the in-progress selection stops updating.
24+
@Test
25+
func pointOverLeadingInsetResolvesToTextOffset() {
26+
layout(left: 60, right: 0)
27+
28+
let overGutter = CGPoint(x: 12, y: 2)
29+
#expect(textView.layoutManager.textOffsetAtPoint(overGutter) == nil, "Precondition: raw point is unresolvable")
30+
31+
let clamped = textView.clampToTextArea(overGutter)
32+
#expect(clamped.x == 60)
33+
#expect(textView.layoutManager.textOffsetAtPoint(clamped) == 0, "Should resolve to the start of line 1")
34+
}
35+
36+
/// Points left of the view entirely — the pointer having been dragged out of the window — clamp the same way.
37+
@Test
38+
func pointLeftOfViewResolvesToLineStart() {
39+
layout(left: 60, right: 0)
40+
41+
let clamped = textView.clampToTextArea(CGPoint(x: -400, y: 2))
42+
#expect(clamped.x == 60)
43+
#expect(textView.layoutManager.textOffsetAtPoint(clamped) == 0)
44+
}
45+
46+
@Test
47+
func pointRightOfViewClampsInsideTrailingInset() {
48+
layout(left: 60, right: 40)
49+
#expect(textView.frame.width == 500, "Precondition: the view is wider than its insets")
50+
51+
let clamped = textView.clampToTextArea(CGPoint(x: 9_000, y: 2))
52+
#expect(clamped.x == 460)
53+
#expect(textView.layoutManager.textOffsetAtPoint(clamped) != nil)
54+
}
55+
56+
@Test
57+
func pointInsideTextAreaIsUnchanged() {
58+
layout(left: 60, right: 40)
59+
60+
let inside = CGPoint(x: 200, y: 2)
61+
#expect(textView.clampToTextArea(inside) == inside)
62+
}
63+
64+
@Test
65+
func verticalPositionsClampToTheView() {
66+
layout(left: 60, right: 0)
67+
68+
#expect(textView.clampToTextArea(CGPoint(x: 100, y: -500)).y == 0)
69+
#expect(textView.clampToTextArea(CGPoint(x: 100, y: 9_000)).y == textView.frame.height)
70+
}
71+
72+
/// With insets wider than the view, the clamp must not produce an inverted range.
73+
@Test
74+
func degenerateInsetsDoNotInvert() {
75+
layout(left: 60, right: 40, width: 50)
76+
77+
#expect(textView.clampToTextArea(CGPoint(x: 10, y: 2)).x == 60)
78+
}
79+
80+
/// The drag anchor is set on mouse down, not on the first drag event, so a selection starts exactly where the
81+
/// user pressed rather than wherever the pointer had traveled to by the time the first drag event arrived.
82+
@Test
83+
func mouseDownSetsDragAnchor() throws {
84+
layout(left: 60, right: 0)
85+
#expect(textView.mouseDragAnchor == nil)
86+
87+
let event = try #require(
88+
NSEvent.mouseEvent(
89+
with: .leftMouseDown,
90+
location: NSPoint(x: 100, y: 2),
91+
modifierFlags: [],
92+
timestamp: 0,
93+
windowNumber: 0,
94+
context: nil,
95+
eventNumber: 0,
96+
clickCount: 1,
97+
pressure: 1
98+
)
99+
)
100+
textView.mouseDown(with: event)
101+
102+
// The view has no window, so the window→view conversion isn't meaningful enough to assert an exact point;
103+
// what matters is that the anchor exists before any drag event arrives, and that it's resolvable.
104+
let anchor = try #require(textView.mouseDragAnchor)
105+
#expect(textView.layoutManager.textOffsetAtPoint(anchor) != nil)
106+
}
107+
}

0 commit comments

Comments
 (0)