Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// CursorSelectionMode.swift
// CodeEditTextView
//
// Created by Abe Malla on 3/31/25.
//

enum CursorSelectionMode {
case character
case word
case line
}
47 changes: 42 additions & 5 deletions Sources/CodeEditTextView/TextView/TextView+Mouse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ extension TextView {
/// if shift, we extend the selection to the click location
/// else we set the cursor
fileprivate func handleSingleClick(event: NSEvent, offset: Int) {
cursorSelectionMode = .character

guard isEditable else {
super.mouseDown(with: event)
return
Expand All @@ -59,6 +61,8 @@ extension TextView {
}

fileprivate func handleDoubleClick(event: NSEvent) {
cursorSelectionMode = .word

guard !event.modifierFlags.contains(.shift) else {
super.mouseDown(with: event)
return
Expand All @@ -68,6 +72,8 @@ extension TextView {
}

fileprivate func handleTripleClick(event: NSEvent) {
cursorSelectionMode = .line

guard !event.modifierFlags.contains(.shift) else {
super.mouseDown(with: event)
return
Expand Down Expand Up @@ -97,12 +103,43 @@ extension TextView {
let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else {
return
}
selectionManager.setSelectedRange(
NSRange(
location: min(startPosition, endPosition),
length: max(startPosition, endPosition) - min(startPosition, endPosition)

switch cursorSelectionMode {
case .character:
selectionManager.setSelectedRange(
NSRange(
location: min(startPosition, endPosition),
length: max(startPosition, endPosition) - min(startPosition, endPosition)
)
)

case .word:
let startWordRange = findWordBoundary(at: startPosition)
let endWordRange = findWordBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startWordRange.location, endWordRange.location),
length: max(startWordRange.location + startWordRange.length,
endWordRange.location + endWordRange.length) -
min(startWordRange.location, endWordRange.location)
)
)

case .line:
let startLineRange = findLineBoundary(at: startPosition)
let endLineRange = findLineBoundary(at: endPosition)

selectionManager.setSelectedRange(
NSRange(
location: min(startLineRange.location, endLineRange.location),
length: max(startLineRange.location + startLineRange.length,
endLineRange.location + endLineRange.length) -
min(startLineRange.location, endLineRange.location)
)
)
)
}

setNeedsDisplay()
self.autoscroll(with: event)
}
Expand Down
70 changes: 44 additions & 26 deletions Sources/CodeEditTextView/TextView/TextView+Select.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,53 @@ extension TextView {

override public func selectWord(_ sender: Any?) {
let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in
guard textSelection.range.isEmpty,
let char = textStorage.substring(
from: NSRange(location: textSelection.range.location, length: 1)
)?.first else {
return nil
}
let charSet = CharacterSet(charactersIn: String(char))
let characterSet: CharacterSet
if CharacterSet.codeIdentifierCharacters.isSuperset(of: charSet) {
characterSet = .codeIdentifierCharacters
} else if CharacterSet.whitespaces.isSuperset(of: charSet) {
characterSet = .whitespaces
} else if CharacterSet.newlines.isSuperset(of: charSet) {
characterSet = .newlines
} else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) {
characterSet = .punctuationCharacters
} else {
return nil
}
guard let start = textStorage
.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.location),
let end = textStorage
.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: textSelection.range.max) else {
return nil
guard textSelection.range.isEmpty else {
return nil
}
return findWordBoundary(at: textSelection.range.location)
}
return NSRange(start: start, end: end)
}
selectionManager.setSelectedRanges(newSelections)
unmarkTextIfNeeded()
needsDisplay = true
}

/// Given a position, find the range of the word that exists at that position.
internal func findWordBoundary(at position: Int) -> NSRange {
guard position >= 0 && position < textStorage.length,
let char = textStorage.substring(
from: NSRange(location: position, length: 1)
)?.first else {
return NSRange(location: position, length: 0)
}

let charSet = CharacterSet(charactersIn: String(char))
let characterSet: CharacterSet

if CharacterSet.codeIdentifierCharacters.isSuperset(of: charSet) {
characterSet = .codeIdentifierCharacters
} else if CharacterSet.whitespaces.isSuperset(of: charSet) {
characterSet = .whitespaces
} else if CharacterSet.newlines.isSuperset(of: charSet) {
characterSet = .newlines
} else if CharacterSet.punctuationCharacters.isSuperset(of: charSet) {
characterSet = .punctuationCharacters
} else {
return NSRange(location: position, length: 0)
}

guard let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position),
let end = textStorage.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: position) else {
return NSRange(location: position, length: 0)
}

return NSRange(start: start, end: end)
}

/// Given a position, find the range of the entire line that exists at that position.
internal func findLineBoundary(at position: Int) -> NSRange {
guard let linePosition = layoutManager.textLineForOffset(position) else {
return NSRange(location: position, length: 0)
}
return linePosition.range
}
}
1 change: 1 addition & 0 deletions Sources/CodeEditTextView/TextView/TextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ public class TextView: NSView, NSTextContent {
var isFirstResponder: Bool = false
var mouseDragAnchor: CGPoint?
var mouseDragTimer: Timer?
var cursorSelectionMode: CursorSelectionMode = .character

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