Skip to content

Commit e86ca59

Browse files
Added mouse drag selection modes (#75)
### Description There are 3 different selection modes that editors typically have when clicking and dragging the mouse. 1. Character - simple click and drag to select by character 2. Word - double click a word and then drag to start selecting by word ranges 3. Line - triple click and drag to start selecting by entire lines This PR tracks and implements the different selection mode behaviors. ### Related Issues Closes #74 ### 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 Character selection (default behavior, unchanged) https://github.com/user-attachments/assets/6f863cfb-35e9-4871-8dbb-9c21f97fdf47 Double click for word selection https://github.com/user-attachments/assets/0c84ba50-0a0d-4e04-8b98-565cdcab8580 Triple click for line selection https://github.com/user-attachments/assets/df700d71-e72e-4ea7-9c04-76d212009d1f
1 parent 33c0971 commit e86ca59

File tree

4 files changed

+99
-31
lines changed

4 files changed

+99
-31
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// CursorSelectionMode.swift
3+
// CodeEditTextView
4+
//
5+
// Created by Abe Malla on 3/31/25.
6+
//
7+
8+
enum CursorSelectionMode {
9+
case character
10+
case word
11+
case line
12+
}

Sources/CodeEditTextView/TextView/TextView+Mouse.swift

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ extension TextView {
4242
/// if shift, we extend the selection to the click location
4343
/// else we set the cursor
4444
fileprivate func handleSingleClick(event: NSEvent, offset: Int) {
45+
cursorSelectionMode = .character
46+
4547
guard isEditable else {
4648
super.mouseDown(with: event)
4749
return
@@ -59,6 +61,8 @@ extension TextView {
5961
}
6062

6163
fileprivate func handleDoubleClick(event: NSEvent) {
64+
cursorSelectionMode = .word
65+
6266
guard !event.modifierFlags.contains(.shift) else {
6367
super.mouseDown(with: event)
6468
return
@@ -68,6 +72,8 @@ extension TextView {
6872
}
6973

7074
fileprivate func handleTripleClick(event: NSEvent) {
75+
cursorSelectionMode = .line
76+
7177
guard !event.modifierFlags.contains(.shift) else {
7278
super.mouseDown(with: event)
7379
return
@@ -97,12 +103,43 @@ extension TextView {
97103
let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else {
98104
return
99105
}
100-
selectionManager.setSelectedRange(
101-
NSRange(
102-
location: min(startPosition, endPosition),
103-
length: max(startPosition, endPosition) - min(startPosition, endPosition)
106+
107+
switch cursorSelectionMode {
108+
case .character:
109+
selectionManager.setSelectedRange(
110+
NSRange(
111+
location: min(startPosition, endPosition),
112+
length: max(startPosition, endPosition) - min(startPosition, endPosition)
113+
)
114+
)
115+
116+
case .word:
117+
let startWordRange = findWordBoundary(at: startPosition)
118+
let endWordRange = findWordBoundary(at: endPosition)
119+
120+
selectionManager.setSelectedRange(
121+
NSRange(
122+
location: min(startWordRange.location, endWordRange.location),
123+
length: max(startWordRange.location + startWordRange.length,
124+
endWordRange.location + endWordRange.length) -
125+
min(startWordRange.location, endWordRange.location)
126+
)
127+
)
128+
129+
case .line:
130+
let startLineRange = findLineBoundary(at: startPosition)
131+
let endLineRange = findLineBoundary(at: endPosition)
132+
133+
selectionManager.setSelectedRange(
134+
NSRange(
135+
location: min(startLineRange.location, endLineRange.location),
136+
length: max(startLineRange.location + startLineRange.length,
137+
endLineRange.location + endLineRange.length) -
138+
min(startLineRange.location, endLineRange.location)
139+
)
104140
)
105-
)
141+
}
142+
106143
setNeedsDisplay()
107144
self.autoscroll(with: event)
108145
}

Sources/CodeEditTextView/TextView/TextView+Select.swift

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,35 +29,53 @@ extension TextView {
2929

3030
override public func selectWord(_ sender: Any?) {
3131
let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in
32-
guard textSelection.range.isEmpty,
33-
let char = textStorage.substring(
34-
from: NSRange(location: textSelection.range.location, length: 1)
35-
)?.first else {
36-
return nil
37-
}
38-
let charSet = CharacterSet(charactersIn: String(char))
39-
let characterSet: CharacterSet
40-
if CharacterSet.codeIdentifierCharacters.isSuperset(of: charSet) {
41-
characterSet = .codeIdentifierCharacters
42-
} else if CharacterSet.whitespaces.isSuperset(of: charSet) {
43-
characterSet = .whitespaces
44-
} else if CharacterSet.newlines.isSuperset(of: charSet) {
45-
characterSet = .newlines
46-
} else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) {
47-
characterSet = .punctuationCharacters
48-
} else {
49-
return nil
50-
}
51-
guard let start = textStorage
52-
.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.location),
53-
let end = textStorage
54-
.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.max) else {
55-
return nil
32+
guard textSelection.range.isEmpty else {
33+
return nil
34+
}
35+
return findWordBoundary(at: textSelection.range.location)
5636
}
57-
return NSRange(start: start, end: end)
58-
}
5937
selectionManager.setSelectedRanges(newSelections)
6038
unmarkTextIfNeeded()
6139
needsDisplay = true
6240
}
41+
42+
/// Given a position, find the range of the word that exists at that position.
43+
internal func findWordBoundary(at position: Int) -> NSRange {
44+
guard position >= 0 && position < textStorage.length,
45+
let char = textStorage.substring(
46+
from: NSRange(location: position, length: 1)
47+
)?.first else {
48+
return NSRange(location: position, length: 0)
49+
}
50+
51+
let charSet = CharacterSet(charactersIn: String(char))
52+
let characterSet: CharacterSet
53+
54+
if CharacterSet.codeIdentifierCharacters.isSuperset(of: charSet) {
55+
characterSet = .codeIdentifierCharacters
56+
} else if CharacterSet.whitespaces.isSuperset(of: charSet) {
57+
characterSet = .whitespaces
58+
} else if CharacterSet.newlines.isSuperset(of: charSet) {
59+
characterSet = .newlines
60+
} else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) {
61+
characterSet = .punctuationCharacters
62+
} else {
63+
return NSRange(location: position, length: 0)
64+
}
65+
66+
guard let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position),
67+
let end = textStorage.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: position) else {
68+
return NSRange(location: position, length: 0)
69+
}
70+
71+
return NSRange(start: start, end: end)
72+
}
73+
74+
/// Given a position, find the range of the entire line that exists at that position.
75+
internal func findLineBoundary(at position: Int) -> NSRange {
76+
guard let linePosition = layoutManager.textLineForOffset(position) else {
77+
return NSRange(location: position, length: 0)
78+
}
79+
return linePosition.range
80+
}
6381
}

Sources/CodeEditTextView/TextView/TextView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ public class TextView: NSView, NSTextContent {
248248
var isFirstResponder: Bool = false
249249
var mouseDragAnchor: CGPoint?
250250
var mouseDragTimer: Timer?
251+
var cursorSelectionMode: CursorSelectionMode = .character
251252

252253
private var fontCharWidth: CGFloat {
253254
(" " as NSString).size(withAttributes: [.font: font]).width

0 commit comments

Comments
 (0)