diff --git a/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift b/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift new file mode 100644 index 000000000..7a7da3ed0 --- /dev/null +++ b/Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift @@ -0,0 +1,12 @@ +// +// CursorSelectionMode.swift +// CodeEditTextView +// +// Created by Abe Malla on 3/31/25. +// + +enum CursorSelectionMode { + case character + case word + case line +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 3a4839fe1..dc752e7c7 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -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 @@ -59,6 +61,8 @@ extension TextView { } fileprivate func handleDoubleClick(event: NSEvent) { + cursorSelectionMode = .word + guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return @@ -68,6 +72,8 @@ extension TextView { } fileprivate func handleTripleClick(event: NSEvent) { + cursorSelectionMode = .line + guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return @@ -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) } diff --git a/Sources/CodeEditTextView/TextView/TextView+Select.swift b/Sources/CodeEditTextView/TextView/TextView+Select.swift index f3da417db..390b6225d 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Select.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Select.swift @@ -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 + } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 84a0a46d6..29eb1736c 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -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