From 6955a7ae5eef7d23183fef3e4e3dbd22e437a2a6 Mon Sep 17 00:00:00 2001 From: Abe M Date: Sun, 30 Mar 2025 06:07:14 -0700 Subject: [PATCH 1/2] Added selection modes --- .../TextSelectionManager/TextSelection.swift | 6 ++ .../TextView/TextView+Mouse.swift | 47 +++++++++++-- .../TextView/TextView+Select.swift | 70 ++++++++++++------- .../CodeEditTextView/TextView/TextView.swift | 1 + 4 files changed, 93 insertions(+), 31 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift index eef33ae75..f3d2ac2c6 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift @@ -34,6 +34,12 @@ public extension TextSelectionManager { lhs.range == rhs.range } } + + enum SelectionMode { + case character + case word + case line + } } private extension TextSelectionManager.TextSelection { diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 3a4839fe1..fc8b60c31 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) { + selectionMode = .character + guard isEditable else { super.mouseDown(with: event) return @@ -59,6 +61,8 @@ extension TextView { } fileprivate func handleDoubleClick(event: NSEvent) { + selectionMode = .word + guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) return @@ -68,6 +72,8 @@ extension TextView { } fileprivate func handleTripleClick(event: NSEvent) { + selectionMode = .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 selectionMode { + 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..54d4124e0 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 selectionMode: TextSelectionManager.SelectionMode = .character private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width From 2777706c4f0462cab6a8fd82575f92a5505d6c1a Mon Sep 17 00:00:00 2001 From: Abe M Date: Mon, 31 Mar 2025 06:06:02 -0700 Subject: [PATCH 2/2] Refactor --- .../Cursors/CursorSelectionMode.swift | 12 ++++++++++++ .../TextSelectionManager/TextSelection.swift | 6 ------ .../CodeEditTextView/TextView/TextView+Mouse.swift | 8 ++++---- Sources/CodeEditTextView/TextView/TextView.swift | 2 +- 4 files changed, 17 insertions(+), 11 deletions(-) create mode 100644 Sources/CodeEditTextView/Cursors/CursorSelectionMode.swift 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/TextSelectionManager/TextSelection.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift index f3d2ac2c6..eef33ae75 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelection.swift @@ -34,12 +34,6 @@ public extension TextSelectionManager { lhs.range == rhs.range } } - - enum SelectionMode { - case character - case word - case line - } } private extension TextSelectionManager.TextSelection { diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index fc8b60c31..dc752e7c7 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -42,7 +42,7 @@ extension TextView { /// if shift, we extend the selection to the click location /// else we set the cursor fileprivate func handleSingleClick(event: NSEvent, offset: Int) { - selectionMode = .character + cursorSelectionMode = .character guard isEditable else { super.mouseDown(with: event) @@ -61,7 +61,7 @@ extension TextView { } fileprivate func handleDoubleClick(event: NSEvent) { - selectionMode = .word + cursorSelectionMode = .word guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) @@ -72,7 +72,7 @@ extension TextView { } fileprivate func handleTripleClick(event: NSEvent) { - selectionMode = .line + cursorSelectionMode = .line guard !event.modifierFlags.contains(.shift) else { super.mouseDown(with: event) @@ -104,7 +104,7 @@ extension TextView { return } - switch selectionMode { + switch cursorSelectionMode { case .character: selectionManager.setSelectedRange( NSRange( diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 54d4124e0..29eb1736c 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -248,7 +248,7 @@ public class TextView: NSView, NSTextContent { var isFirstResponder: Bool = false var mouseDragAnchor: CGPoint? var mouseDragTimer: Timer? - var selectionMode: TextSelectionManager.SelectionMode = .character + var cursorSelectionMode: CursorSelectionMode = .character private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width