Skip to content

Commit 85122d5

Browse files
committed
Merge branch 'main' into feat/track-drag-outside-view
2 parents d6babde + 3f96de5 commit 85122d5

File tree

8 files changed

+151
-60
lines changed

8 files changed

+151
-60
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,22 @@ extension TextLayoutManager {
7575
) else {
7676
return nil
7777
}
78-
let fragment = fragmentPosition.data
7978

79+
return textOffsetAtPoint(point, fragmentPosition: fragmentPosition, linePosition: linePosition)
80+
}
81+
82+
func textOffsetAtPoint(
83+
_ point: CGPoint,
84+
fragmentPosition: TextLineStorage<LineFragment>.TextLinePosition,
85+
linePosition: TextLineStorage<TextLine>.TextLinePosition
86+
) -> Int? {
87+
let fragment = fragmentPosition.data
8088
if fragment.width == 0 {
8189
return linePosition.range.location + fragmentPosition.range.location
8290
} else if fragment.width <= point.x - edgeInsets.left {
8391
return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition)
8492
} else {
85-
return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition)
93+
return findOffsetAtPoint(inFragment: fragment, xPos: point.x, inLine: linePosition)
8694
}
8795
}
8896

@@ -125,23 +133,23 @@ extension TextLayoutManager {
125133
/// Finds a document offset for a point that lies in a line fragment.
126134
/// - Parameters:
127135
/// - fragment: The fragment the point lies in.
128-
/// - point: The point being queried, relative to the text view.
136+
/// - xPos: The point being queried, relative to the text view.
129137
/// - linePosition: The position that contains the `fragment`.
130138
/// - Returns: The offset (relative to the document) that's closest to the given point, or `nil` if it could not be
131139
/// found.
132-
private func findOffsetAtPoint(
140+
func findOffsetAtPoint(
133141
inFragment fragment: LineFragment,
134-
point: CGPoint,
142+
xPos: CGFloat,
135143
inLine linePosition: TextLineStorage<TextLine>.TextLinePosition
136144
) -> Int? {
137-
guard let (content, contentPosition) = fragment.findContent(atX: point.x - edgeInsets.left) else {
145+
guard let (content, contentPosition) = fragment.findContent(atX: xPos - edgeInsets.left) else {
138146
return nil
139147
}
140148
switch content.data {
141149
case .text(let ctLine):
142150
let fragmentIndex = CTLineGetStringIndexForPosition(
143151
ctLine,
144-
CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
152+
CGPoint(x: xPos - edgeInsets.left - contentPosition.xPos, y: fragment.height/2)
145153
)
146154
return fragmentIndex + contentPosition.offset + linePosition.range.location
147155
case .attachment:

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public class TextSelectionManager: NSObject {
9595
(0...(textStorage?.length ?? 0)).contains($0.location)
9696
&& (0...(textStorage?.length ?? 0)).contains($0.max)
9797
}
98+
.sorted(by: { $0.location < $1.location })
9899
.map {
99100
let selection = TextSelection(range: $0)
100101
selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX
@@ -127,6 +128,7 @@ public class TextSelectionManager: NSObject {
127128
}
128129
if !didHandle {
129130
textSelections.append(newTextSelection)
131+
textSelections.sort(by: { $0.range.location < $1.range.location })
130132
}
131133

132134
updateSelectionViews()
@@ -136,72 +138,80 @@ public class TextSelectionManager: NSObject {
136138

137139
// MARK: - Selection Views
138140

139-
/// Update all selection cursors. Placing them in the correct position for each text selection and reseting the
140-
/// blink timer.
141-
func updateSelectionViews(force: Bool = false) {
141+
/// Update all selection cursors. Placing them in the correct position for each text selection and
142+
/// optionally reseting the blink timer.
143+
func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) {
142144
guard textView?.isFirstResponder ?? false else { return }
143145
var didUpdate: Bool = false
144146

145147
for textSelection in textSelections {
146148
if textSelection.range.isEmpty {
147-
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
148-
continue
149-
}
150-
151-
var doesViewNeedReposition: Bool
152-
153-
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
154-
// approximate equals in that case to avoid extra updates.
155-
if useSystemCursor, #available(macOS 14.0, *) {
156-
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
157-
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
158-
} else {
159-
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
160-
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
161-
}
162-
163-
if textSelection.view == nil || doesViewNeedReposition {
164-
let cursorView: NSView
149+
didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection)
150+
} else if !textSelection.range.isEmpty && textSelection.view != nil {
151+
textSelection.view?.removeFromSuperview()
152+
textSelection.view = nil
153+
didUpdate = true
154+
}
155+
}
165156

166-
if let existingCursorView = textSelection.view {
167-
cursorView = existingCursorView
168-
} else {
169-
textSelection.view?.removeFromSuperview()
170-
textSelection.view = nil
157+
if didUpdate || force {
158+
delegate?.setNeedsDisplay()
159+
if !skipTimerReset {
160+
cursorTimer.resetTimer()
161+
resetSystemCursorTimers()
162+
}
163+
}
164+
}
171165

172-
if useSystemCursor, #available(macOS 14.0, *) {
173-
let systemCursorView = NSTextInsertionIndicator(frame: .zero)
174-
cursorView = systemCursorView
175-
systemCursorView.displayMode = .automatic
176-
} else {
177-
let internalCursorView = CursorView(color: insertionPointColor)
178-
cursorView = internalCursorView
179-
cursorTimer.register(internalCursorView)
180-
}
166+
private func repositionCursorSelection(textSelection: TextSelection) -> Bool {
167+
guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else {
168+
return false
169+
}
181170

182-
textView?.addSubview(cursorView, positioned: .above, relativeTo: nil)
183-
}
171+
var doesViewNeedReposition: Bool
184172

185-
cursorView.frame.origin = cursorRect.origin
186-
cursorView.frame.size.height = cursorRect.height
173+
// If using the system cursor, macOS will change the origin and height by about 0.5, so we do an
174+
// approximate equals in that case to avoid extra updates.
175+
if useSystemCursor, #available(macOS 14.0, *) {
176+
doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin)
177+
|| !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0)
178+
} else {
179+
doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin
180+
|| textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0
181+
}
187182

188-
textSelection.view = cursorView
189-
textSelection.boundingRect = cursorView.frame
183+
if textSelection.view == nil || doesViewNeedReposition {
184+
let cursorView: NSView
190185

191-
didUpdate = true
192-
}
193-
} else if !textSelection.range.isEmpty && textSelection.view != nil {
186+
if let existingCursorView = textSelection.view {
187+
cursorView = existingCursorView
188+
} else {
194189
textSelection.view?.removeFromSuperview()
195190
textSelection.view = nil
196-
didUpdate = true
191+
192+
if useSystemCursor, #available(macOS 14.0, *) {
193+
let systemCursorView = NSTextInsertionIndicator(frame: .zero)
194+
cursorView = systemCursorView
195+
systemCursorView.displayMode = .automatic
196+
} else {
197+
let internalCursorView = CursorView(color: insertionPointColor)
198+
cursorView = internalCursorView
199+
cursorTimer.register(internalCursorView)
200+
}
201+
202+
textView?.addSubview(cursorView, positioned: .above, relativeTo: nil)
197203
}
198-
}
199204

200-
if didUpdate || force {
201-
delegate?.setNeedsDisplay()
202-
cursorTimer.resetTimer()
203-
resetSystemCursorTimers()
205+
cursorView.frame.origin = cursorRect.origin
206+
cursorView.frame.size.height = cursorRect.height
207+
208+
textSelection.view = cursorView
209+
textSelection.boundingRect = cursorView.frame
210+
211+
return true
204212
}
213+
214+
return false
205215
}
206216

207217
private func resetSystemCursorTimers() {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// TextView+ColumnSelection.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Khan Winter on 6/19/25.
6+
//
7+
8+
import AppKit
9+
10+
extension TextView {
11+
/// Set the user's selection to a square region in the editor.
12+
///
13+
/// This method will automatically determine a valid region from the provided two points.
14+
/// - Parameters:
15+
/// - pointA: The first point.
16+
/// - pointB: The second point.
17+
public func selectColumns(betweenPointA pointA: CGPoint, pointB: CGPoint) {
18+
let start = CGPoint(x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y))
19+
let end = CGPoint(x: max(pointA.x, pointB.x), y: max(pointA.y, pointB.y))
20+
21+
// Collect all overlapping text ranges
22+
var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in
23+
// Collect fragment ranges
24+
return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in
25+
let startOffset = self.layoutManager.textOffsetAtPoint(
26+
start,
27+
fragmentPosition: lineFragment,
28+
linePosition: textLine
29+
)
30+
let endOffset = self.layoutManager.textOffsetAtPoint(
31+
end,
32+
fragmentPosition: lineFragment,
33+
linePosition: textLine
34+
)
35+
guard let startOffset, let endOffset else { return nil }
36+
37+
return NSRange(start: startOffset, end: endOffset)
38+
}
39+
}
40+
41+
// If we have some non-cursor selections, filter out any cursor selections
42+
if selectedRanges.contains(where: { !$0.isEmpty }) {
43+
selectedRanges = selectedRanges.filter({
44+
!$0.isEmpty || (layoutManager.rectForOffset($0.location)?.origin.x.approxEqual(start.x) ?? false)
45+
})
46+
}
47+
48+
selectionManager.setSelectedRanges(selectedRanges)
49+
}
50+
}

Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ extension TextView {
5151
open override func resetCursorRects() {
5252
super.resetCursorRects()
5353
if isSelectable {
54-
addCursorRect(visibleRect, cursor: .iBeam)
54+
addCursorRect(
55+
visibleRect,
56+
cursor: isOptionPressed ? .crosshair : .iBeam
57+
)
5558
}
5659
}
5760
}

Sources/CodeEditTextView/TextView/TextView+KeyDown.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,16 @@ extension TextView {
4747

4848
return false
4949
}
50+
51+
override public func flagsChanged(with event: NSEvent) {
52+
super.flagsChanged(with: event)
53+
54+
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
55+
let modifierFlagsIsOption = modifierFlags == [.option]
56+
57+
if modifierFlagsIsOption != isOptionPressed {
58+
isOptionPressed = modifierFlagsIsOption
59+
resetCursorRects()
60+
}
61+
}
5062
}

Sources/CodeEditTextView/TextView/TextView+Layout.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension TextView {
1111
override public func layout() {
1212
super.layout()
1313
layoutManager.layoutLines()
14+
selectionManager.updateSelectionViews(skipTimerReset: true)
1415
}
1516

1617
open override class var isCompatibleWithResponsiveScrolling: Bool {

Sources/CodeEditTextView/TextView/TextView+Mouse.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ extension TextView {
4141
super.mouseDown(with: event)
4242
return
4343
}
44-
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) {
44+
let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
45+
if eventFlags == [.control, .shift] {
4546
unmarkText()
4647
selectionManager.addSelectedRange(NSRange(location: offset, length: 0))
47-
} else if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) {
48+
} else if eventFlags.contains(.shift) {
4849
unmarkText()
4950
shiftClickExtendSelection(to: offset)
5051
} else {
@@ -143,6 +144,8 @@ extension TextView {
143144
setNeedsDisplay()
144145
}
145146

147+
// MARK: - Mouse Autoscroll
148+
146149
/// Sets up a timer that fires at a predetermined period to autoscroll the text view.
147150
/// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``.
148151
func setUpMouseAutoscrollTimer() {
@@ -162,6 +165,8 @@ extension TextView {
162165
mouseDragTimer = nil
163166
}
164167

168+
// MARK: - Drag Selection
169+
165170
private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) {
166171
switch cursorSelectionMode {
167172
case .character:

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,8 @@ public class TextView: NSView, NSTextContent {
269269
var draggingCursorView: NSView?
270270
var isDragging: Bool = false
271271

272+
var isOptionPressed: Bool = false
273+
272274
private var fontCharWidth: CGFloat {
273275
(" " as NSString).size(withAttributes: [.font: font]).width
274276
}

0 commit comments

Comments
 (0)