From 4f78bc961461a538aa4a08ed4ba53c9014acf262 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:06:54 -0500 Subject: [PATCH 1/3] Column Selection --- .../TextLayoutManager+Public.swift | 22 +++- .../TextView/TextView+Mouse.swift | 123 +++++++++++++----- 2 files changed, 102 insertions(+), 43 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index bca881d05..19f67793a 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -75,14 +75,22 @@ extension TextLayoutManager { ) else { return nil } - let fragment = fragmentPosition.data + return textOffsetAtPoint(point, fragmentPosition: fragmentPosition, linePosition: linePosition) + } + + func textOffsetAtPoint( + _ point: CGPoint, + fragmentPosition: TextLineStorage.TextLinePosition, + linePosition: TextLineStorage.TextLinePosition + ) -> Int? { + let fragment = fragmentPosition.data if fragment.width == 0 { return linePosition.range.location + fragmentPosition.range.location } else if fragment.width <= point.x - edgeInsets.left { return findOffsetAfterEndOf(fragmentPosition: fragmentPosition, in: linePosition) } else { - return findOffsetAtPoint(inFragment: fragment, point: point, inLine: linePosition) + return findOffsetAtPoint(inFragment: fragment, xPos: point.x, inLine: linePosition) } } @@ -125,23 +133,23 @@ extension TextLayoutManager { /// Finds a document offset for a point that lies in a line fragment. /// - Parameters: /// - fragment: The fragment the point lies in. - /// - point: The point being queried, relative to the text view. + /// - xPos: The point being queried, relative to the text view. /// - linePosition: The position that contains the `fragment`. /// - Returns: The offset (relative to the document) that's closest to the given point, or `nil` if it could not be /// found. - private func findOffsetAtPoint( + func findOffsetAtPoint( inFragment fragment: LineFragment, - point: CGPoint, + xPos: CGFloat, inLine linePosition: TextLineStorage.TextLinePosition ) -> Int? { - guard let (content, contentPosition) = fragment.findContent(atX: point.x - edgeInsets.left) else { + guard let (content, contentPosition) = fragment.findContent(atX: xPos - edgeInsets.left) else { return nil } switch content.data { case .text(let ctLine): let fragmentIndex = CTLineGetStringIndexForPosition( ctLine, - CGPoint(x: point.x - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) + CGPoint(x: xPos - edgeInsets.left - contentPosition.xPos, y: fragment.height/2) ) return fragmentIndex + contentPosition.offset + linePosition.range.location case .attachment: diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index db1a96d1b..f622be646 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -41,10 +41,11 @@ extension TextView { super.mouseDown(with: event) return } - if event.modifierFlags.intersection(.deviceIndependentFlagsMask).isSuperset(of: [.control, .shift]) { + let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if eventFlags == [.control, .shift] { unmarkText() selectionManager.addSelectedRange(NSRange(location: offset, length: 0)) - } else if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.shift) { + } else if eventFlags.contains(.shift) { unmarkText() shiftClickExtendSelection(to: offset) } else { @@ -96,40 +97,11 @@ extension TextView { return } - 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) - ) - ) + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifierFlags.contains(.option) { + dragColumnSelection(mouseDragAnchor: mouseDragAnchor, event: event) + } else { + dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor) } setNeedsDisplay() @@ -182,4 +154,83 @@ extension TextView { mouseDragTimer?.invalidate() mouseDragTimer = nil } + + private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) { + 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) + ) + ) + } + } + + private func dragColumnSelection(mouseDragAnchor: CGPoint, event: NSEvent) { + // Drag the selection and select in columns + let eventLocation = convert(event.locationInWindow, from: nil) + + let start = CGPoint( + x: min(mouseDragAnchor.x, eventLocation.x), + y: min(mouseDragAnchor.y, eventLocation.y) + ) + let end = CGPoint( + x: max(mouseDragAnchor.x, eventLocation.x), + y: max(mouseDragAnchor.y, eventLocation.y) + ) + + // Collect all overlapping text ranges + var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in + // Collect fragment ranges + return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in + let startOffset = self.layoutManager.textOffsetAtPoint( + start, + fragmentPosition: lineFragment, + linePosition: textLine + ) + let endOffset = self.layoutManager.textOffsetAtPoint( + end, + fragmentPosition: lineFragment, + linePosition: textLine + ) + guard let startOffset, let endOffset else { return nil } + + return NSRange(start: startOffset, end: endOffset) + } + } + + // If we have some non-cursor selections, filter out any cursor selections + if selectedRanges.contains(where: { !$0.isEmpty }) { + selectedRanges = selectedRanges.filter({ !$0.isEmpty }) + } + + selectionManager.setSelectedRanges(selectedRanges) + } } From dd109ec7f3536bfecd33269641eeedc497cce289 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:34:41 -0500 Subject: [PATCH 2/3] Use Crosshair Cursor --- .../TextView/TextView+FirstResponder.swift | 5 ++++- .../CodeEditTextView/TextView/TextView+KeyDown.swift | 10 ++++++++++ Sources/CodeEditTextView/TextView/TextView.swift | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift index 39588a262..968c1ede3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift +++ b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift @@ -51,7 +51,10 @@ extension TextView { open override func resetCursorRects() { super.resetCursorRects() if isSelectable { - addCursorRect(visibleRect, cursor: .iBeam) + addCursorRect( + visibleRect, + cursor: isOptionPressed ? .crosshair : .iBeam + ) } } } diff --git a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift index 1ef36d4f5..4e9441ed1 100644 --- a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -47,4 +47,14 @@ extension TextView { return false } + + override public func flagsChanged(with event: NSEvent) { + super.flagsChanged(with: event) + + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if modifierFlags.contains(.option) != isOptionPressed { + isOptionPressed = modifierFlags.contains(.option) + resetCursorRects() + } + } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 873694591..f7537fb99 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -269,6 +269,8 @@ public class TextView: NSView, NSTextContent { var draggingCursorView: NSView? var isDragging: Bool = false + var isOptionPressed: Bool = false + private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } From 4ccab5d050d967789a5fc087d22fe5d125026af0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:27:04 -0500 Subject: [PATCH 3/3] Clean Up --- .../TextSelectionManager.swift | 2 + .../TextView/TextView+ColumnSelection.swift | 50 +++++++++++++++++++ .../TextView/TextView+KeyDown.swift | 6 ++- .../TextView/TextView+Mouse.swift | 41 ++------------- 4 files changed, 61 insertions(+), 38 deletions(-) create mode 100644 Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index deff69375..0e0e46fd9 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -95,6 +95,7 @@ public class TextSelectionManager: NSObject { (0...(textStorage?.length ?? 0)).contains($0.location) && (0...(textStorage?.length ?? 0)).contains($0.max) } + .sorted(by: { $0.location < $1.location }) .map { let selection = TextSelection(range: $0) selection.suggestedXPos = layoutManager?.rectForOffset($0.location)?.minX @@ -127,6 +128,7 @@ public class TextSelectionManager: NSObject { } if !didHandle { textSelections.append(newTextSelection) + textSelections.sort(by: { $0.range.location < $1.range.location }) } updateSelectionViews() diff --git a/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift new file mode 100644 index 000000000..ad2e63102 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+ColumnSelection.swift @@ -0,0 +1,50 @@ +// +// TextView+ColumnSelection.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/19/25. +// + +import AppKit + +extension TextView { + /// Set the user's selection to a square region in the editor. + /// + /// This method will automatically determine a valid region from the provided two points. + /// - Parameters: + /// - pointA: The first point. + /// - pointB: The second point. + public func selectColumns(betweenPointA pointA: CGPoint, pointB: CGPoint) { + let start = CGPoint(x: min(pointA.x, pointB.x), y: min(pointA.y, pointB.y)) + let end = CGPoint(x: max(pointA.x, pointB.x), y: max(pointA.y, pointB.y)) + + // Collect all overlapping text ranges + var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in + // Collect fragment ranges + return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in + let startOffset = self.layoutManager.textOffsetAtPoint( + start, + fragmentPosition: lineFragment, + linePosition: textLine + ) + let endOffset = self.layoutManager.textOffsetAtPoint( + end, + fragmentPosition: lineFragment, + linePosition: textLine + ) + guard let startOffset, let endOffset else { return nil } + + return NSRange(start: startOffset, end: endOffset) + } + } + + // If we have some non-cursor selections, filter out any cursor selections + if selectedRanges.contains(where: { !$0.isEmpty }) { + selectedRanges = selectedRanges.filter({ + !$0.isEmpty || (layoutManager.rectForOffset($0.location)?.origin.x.approxEqual(start.x) ?? false) + }) + } + + selectionManager.setSelectedRanges(selectedRanges) + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift index 4e9441ed1..e11187851 100644 --- a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -52,8 +52,10 @@ extension TextView { super.flagsChanged(with: event) let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if modifierFlags.contains(.option) != isOptionPressed { - isOptionPressed = modifierFlags.contains(.option) + let modifierFlagsIsOption = modifierFlags == [.option] + + if modifierFlagsIsOption != isOptionPressed { + isOptionPressed = modifierFlagsIsOption resetCursorRects() } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index f622be646..a6e447999 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -136,6 +136,8 @@ extension TextView { setNeedsDisplay() } + // MARK: - Mouse Autoscroll + /// Sets up a timer that fires at a predetermined period to autoscroll the text view. /// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``. func setUpMouseAutoscrollTimer() { @@ -155,6 +157,8 @@ extension TextView { mouseDragTimer = nil } + // MARK: - Drag Selection + private func dragSelection(startPosition: Int, endPosition: Int, mouseDragAnchor: CGPoint) { switch cursorSelectionMode { case .character: @@ -196,41 +200,6 @@ extension TextView { private func dragColumnSelection(mouseDragAnchor: CGPoint, event: NSEvent) { // Drag the selection and select in columns let eventLocation = convert(event.locationInWindow, from: nil) - - let start = CGPoint( - x: min(mouseDragAnchor.x, eventLocation.x), - y: min(mouseDragAnchor.y, eventLocation.y) - ) - let end = CGPoint( - x: max(mouseDragAnchor.x, eventLocation.x), - y: max(mouseDragAnchor.y, eventLocation.y) - ) - - // Collect all overlapping text ranges - var selectedRanges: [NSRange] = layoutManager.linesStartingAt(start.y, until: end.y).flatMap { textLine in - // Collect fragment ranges - return textLine.data.lineFragments.compactMap { lineFragment -> NSRange? in - let startOffset = self.layoutManager.textOffsetAtPoint( - start, - fragmentPosition: lineFragment, - linePosition: textLine - ) - let endOffset = self.layoutManager.textOffsetAtPoint( - end, - fragmentPosition: lineFragment, - linePosition: textLine - ) - guard let startOffset, let endOffset else { return nil } - - return NSRange(start: startOffset, end: endOffset) - } - } - - // If we have some non-cursor selections, filter out any cursor selections - if selectedRanges.contains(where: { !$0.isEmpty }) { - selectedRanges = selectedRanges.filter({ !$0.isEmpty }) - } - - selectionManager.setSelectedRanges(selectedRanges) + selectColumns(betweenPointA: eventLocation, pointB: mouseDragAnchor) } }