Skip to content

Commit 3f96de5

Browse files
Column Selection (#107)
### Description Adds column selection when pressing option and dragging to select. ### Related Issues * #46 ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Demo, ignore the multiple undos at the end a fix for that is on it's way. https://github.com/user-attachments/assets/409aa49c-eed2-465f-a964-162920c8877d
1 parent 48cef38 commit 3f96de5

File tree

7 files changed

+141
-44
lines changed

7 files changed

+141
-44
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: 2 additions & 0 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()
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+Mouse.swift

Lines changed: 56 additions & 36 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 {
@@ -96,40 +97,11 @@ extension TextView {
9697
return
9798
}
9899

99-
switch cursorSelectionMode {
100-
case .character:
101-
selectionManager.setSelectedRange(
102-
NSRange(
103-
location: min(startPosition, endPosition),
104-
length: max(startPosition, endPosition) - min(startPosition, endPosition)
105-
)
106-
)
107-
108-
case .word:
109-
let startWordRange = findWordBoundary(at: startPosition)
110-
let endWordRange = findWordBoundary(at: endPosition)
111-
112-
selectionManager.setSelectedRange(
113-
NSRange(
114-
location: min(startWordRange.location, endWordRange.location),
115-
length: max(startWordRange.location + startWordRange.length,
116-
endWordRange.location + endWordRange.length) -
117-
min(startWordRange.location, endWordRange.location)
118-
)
119-
)
120-
121-
case .line:
122-
let startLineRange = findLineBoundary(at: startPosition)
123-
let endLineRange = findLineBoundary(at: endPosition)
124-
125-
selectionManager.setSelectedRange(
126-
NSRange(
127-
location: min(startLineRange.location, endLineRange.location),
128-
length: max(startLineRange.location + startLineRange.length,
129-
endLineRange.location + endLineRange.length) -
130-
min(startLineRange.location, endLineRange.location)
131-
)
132-
)
100+
let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
101+
if modifierFlags.contains(.option) {
102+
dragColumnSelection(mouseDragAnchor: mouseDragAnchor, event: event)
103+
} else {
104+
dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor)
133105
}
134106

135107
setNeedsDisplay()
@@ -164,6 +136,8 @@ extension TextView {
164136
setNeedsDisplay()
165137
}
166138

139+
// MARK: - Mouse Autoscroll
140+
167141
/// Sets up a timer that fires at a predetermined period to autoscroll the text view.
168142
/// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``.
169143
func setUpMouseAutoscrollTimer() {
@@ -182,4 +156,50 @@ extension TextView {
182156
mouseDragTimer?.invalidate()
183157
mouseDragTimer = nil
184158
}
159+
160+
// MARK: - Drag Selection
161+
162+
private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) {
163+
switch cursorSelectionMode {
164+
case .character:
165+
selectionManager.setSelectedRange(
166+
NSRange(
167+
location: min(startPosition, endPosition),
168+
length: max(startPosition, endPosition) - min(startPosition, endPosition)
169+
)
170+
)
171+
172+
case .word:
173+
let startWordRange = findWordBoundary(at: startPosition)
174+
let endWordRange = findWordBoundary(at: endPosition)
175+
176+
selectionManager.setSelectedRange(
177+
NSRange(
178+
location: min(startWordRange.location, endWordRange.location),
179+
length: max(startWordRange.location + startWordRange.length,
180+
endWordRange.location + endWordRange.length) -
181+
min(startWordRange.location, endWordRange.location)
182+
)
183+
)
184+
185+
case .line:
186+
let startLineRange = findLineBoundary(at: startPosition)
187+
let endLineRange = findLineBoundary(at: endPosition)
188+
189+
selectionManager.setSelectedRange(
190+
NSRange(
191+
location: min(startLineRange.location, endLineRange.location),
192+
length: max(startLineRange.location + startLineRange.length,
193+
endLineRange.location + endLineRange.length) -
194+
min(startLineRange.location, endLineRange.location)
195+
)
196+
)
197+
}
198+
}
199+
200+
private func dragColumnSelection(mouseDragAnchor: CGPoint, event: NSEvent) {
201+
// Drag the selection and select in columns
202+
let eventLocation = convert(event.locationInWindow, from: nil)
203+
selectColumns(betweenPointA: eventLocation, pointB: mouseDragAnchor)
204+
}
185205
}

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)