From d6babdeb5d23de953029521f1f33d939cfc88ca7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 18 Jun 2025 16:28:24 -0500 Subject: [PATCH 1/2] Track Mouse Drag Outside View --- .../TextView/TextView+Mouse.swift | 128 +++++++++++++----- 1 file changed, 92 insertions(+), 36 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index db1a96d1b..72222f186 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -86,50 +86,29 @@ extension TextView { return } + // We receive global events because our view received the drag event, but we need to clamp the potentially + // out-of-bounds positions to a position our layout manager can deal with. + let locationInWindow = convert(event.locationInWindow, from: nil) + let locationInView = CGPoint( + x: max(0.0, min(locationInWindow.x, frame.width)), + y: max(0.0, min(locationInWindow.y, frame.height)) + ) + if mouseDragAnchor == nil { - mouseDragAnchor = convert(event.locationInWindow, from: nil) + mouseDragAnchor = locationInView super.mouseDragged(with: event) } else { guard let mouseDragAnchor, let startPosition = layoutManager.textOffsetAtPoint(mouseDragAnchor), - let endPosition = layoutManager.textOffsetAtPoint(convert(event.locationInWindow, from: nil)) else { + let endPosition = layoutManager.textOffsetAtPoint(locationInView) else { 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, locationInView: locationInView) + } else { + dragSelection(startPosition: startPosition, endPosition: endPosition, mouseDragAnchor: mouseDragAnchor) } setNeedsDisplay() @@ -182,4 +161,81 @@ 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, locationInView: CGPoint) { + // Drag the selection and select in columns + let start = CGPoint( + x: min(mouseDragAnchor.x, locationInView.x), + y: min(mouseDragAnchor.y, locationInView.y) + ) + let end = CGPoint( + x: max(mouseDragAnchor.x, locationInView.x), + y: max(mouseDragAnchor.y, locationInView.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 a2b55a45cbd7b10e1ce1e8289aeced44d9c7884a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:25:47 -0500 Subject: [PATCH 2/2] Use Column Selection Method --- .../TextView/TextView+Mouse.swift | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index e2de97f69..636ea472a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -206,41 +206,6 @@ extension TextView { } private func dragColumnSelection(mouseDragAnchor: CGPoint, locationInView: CGPoint) { - // Drag the selection and select in columns - let start = CGPoint( - x: min(mouseDragAnchor.x, locationInView.x), - y: min(mouseDragAnchor.y, locationInView.y) - ) - let end = CGPoint( - x: max(mouseDragAnchor.x, locationInView.x), - y: max(mouseDragAnchor.y, locationInView.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: mouseDragAnchor, pointB: locationInView) } }