diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift index 61ca777f2..f3bc01209 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachment.swift @@ -10,6 +10,7 @@ import AppKit /// Represents an attachment type. Attachments take up some set width, and draw their contents in a receiver view. public protocol TextAttachment: AnyObject { var width: CGFloat { get } + var isSelected: Bool { get set } func draw(in context: CGContext, rect: NSRect) } @@ -18,8 +19,8 @@ public protocol TextAttachment: AnyObject { /// This type cannot be initialized outside of `CodeEditTextView`, but will be received when interrogating /// the ``TextAttachmentManager``. public struct AnyTextAttachment: Equatable { - var range: NSRange - let attachment: any TextAttachment + package(set) public var range: NSRange + public let attachment: any TextAttachment var width: CGFloat { attachment.width diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift index 5bad2de1e..dfa561d84 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextAttachments/TextAttachmentManager.swift @@ -15,6 +15,7 @@ import Foundation public final class TextAttachmentManager { private var orderedAttachments: [AnyTextAttachment] = [] weak var layoutManager: TextLayoutManager? + private var selectionObserver: (any NSObjectProtocol)? /// Adds a new attachment, keeping `orderedAttachments` sorted by range.location. /// If two attachments overlap, the layout phase will later ignore the one with the higher start. @@ -23,11 +24,28 @@ public final class TextAttachmentManager { let attachment = AnyTextAttachment(range: range, attachment: attachment) let insertIndex = findInsertionIndex(for: range.location) orderedAttachments.insert(attachment, at: insertIndex) + + // This is ugly, but if our attachment meets the end of the next line, we need to merge that line with this + // one. + var getNextOne = false layoutManager?.lineStorage.linesInRange(range).dropFirst().forEach { if $0.height != 0 { layoutManager?.lineStorage.update(atOffset: $0.range.location, delta: 0, deltaHeight: -$0.height) } + + // Only do this if it's not the end of the document + if range.max == $0.range.max && range.max != layoutManager?.lineStorage.length { + getNextOne = true + } + } + + if getNextOne, + let trailingLine = layoutManager?.lineStorage.getLine(atOffset: range.max), + trailingLine.height != 0 { + // Update the one trailing line. + layoutManager?.lineStorage.update(atOffset: range.max, delta: 0, deltaHeight: -trailingLine.height) } + layoutManager?.setNeedsLayout() } @@ -77,7 +95,7 @@ public final class TextAttachmentManager { /// - Returns: An array of `AnyTextAttachment` instances whose ranges intersect `query`. public func getAttachmentsOverlapping(_ range: NSRange) -> [AnyTextAttachment] { // Find the first attachment whose end is beyond the start of the query. - guard let startIdx = firstIndex(where: { $0.range.upperBound > range.location }) else { + guard let startIdx = firstIndex(where: { $0.range.upperBound >= range.location }) else { return [] } @@ -90,8 +108,8 @@ public final class TextAttachmentManager { if attachment.range.location >= range.upperBound { break } - if attachment.range.intersection(range)?.length ?? 0 > 0, - results.last?.range != attachment.range { + if (attachment.range.intersection(range)?.length ?? 0 > 0 || attachment.range.max == range.location) + && results.last?.range != attachment.range { results.append(attachment) } idx += 1 @@ -114,6 +132,43 @@ public final class TextAttachmentManager { } } } + + /// Set up the attachment manager to listen to selection updates, giving text attachments a chance to respond to + /// selection state. + /// + /// This is specifically not in the initializer to prevent a bit of a chicken-and-the-egg situation where the + /// layout manager and selection manager need each other to init. + /// + /// - Parameter selectionManager: The selection manager to listen to. + func setUpSelectionListener(for selectionManager: TextSelectionManager) { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + + selectionObserver = NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: selectionManager, + queue: .main + ) { [weak self] notification in + guard let selectionManager = notification.object as? TextSelectionManager else { + return + } + let selectedSet = IndexSet(ranges: selectionManager.textSelections.map({ $0.range })) + for attachment in self?.orderedAttachments ?? [] { + let isSelected = selectedSet.contains(integersIn: attachment.range) + if attachment.attachment.isSelected != isSelected { + self?.layoutManager?.invalidateLayoutForRange(attachment.range) + } + attachment.attachment.isSelected = isSelected + } + } + } + + deinit { + if let selectionObserver { + NotificationCenter.default.removeObserver(selectionObserver) + } + } } private extension TextAttachmentManager { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 4e5efede5..ba0a997ed 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -196,7 +196,7 @@ public extension TextLayoutManager { } if lastAttachment.range.max > originalPosition.position.range.max, - let extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { + var extendedLinePosition = lineStorage.getLine(atOffset: lastAttachment.range.max) { newPosition = TextLineStorage.TextLinePosition( data: newPosition.data, range: NSRange(start: newPosition.range.location, end: extendedLinePosition.range.max), @@ -207,6 +207,14 @@ public extension TextLayoutManager { maxIndex = max(maxIndex, extendedLinePosition.index) } + if firstAttachment.range.location == newPosition.range.location { + minIndex = max(minIndex, 0) + } + + if lastAttachment.range.max == newPosition.range.max { + maxIndex = min(maxIndex, lineStorage.count - 1) + } + // Base case, we haven't updated anything if minIndex...maxIndex == originalPosition.indexRange { return (newPosition, minIndex...maxIndex) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index d7732e37c..074d8fcef 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -250,7 +250,7 @@ extension TextLayoutManager { let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) { renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView() } - view.translatesAutoresizingMaskIntoConstraints = false + view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer) view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos) layoutView?.addSubview(view, positioned: .below, relativeTo: nil) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 6a1a4df61..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: @@ -203,7 +211,7 @@ extension TextLayoutManager { /// - line: The line to calculate rects for. /// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range. public func rectsFor(range: NSRange) -> [CGRect] { - return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } + return linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) } } /// Calculates all text bounding rects that intersect with a given range, with a given line position. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 0985f53d7..503c334c7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -76,7 +76,7 @@ public class TextLayoutManager: NSObject { // MARK: - Internal weak var textStorage: NSTextStorage? - var lineStorage: TextLineStorage = TextLineStorage() + public var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() let lineFragmentRenderer: LineFragmentRenderer diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index da5165f32..f3160bf3e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -72,10 +72,15 @@ extension TextSelectionManager { } let maxRect: CGRect + let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max) + let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length + let emptyLine = linePosition.range.isEmpty + // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. - if (fragmentRange.max <= range.max || range.contains(fragmentRange.max)) - && intersectionRange.max != layoutManager.lineStorage.length { + // true, !true = false, false + // true, !true = false, true + if endOfLine && !(endOfDocument && !emptyLine) { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index deff69375..2bc3d1d4c 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() @@ -136,72 +138,80 @@ public class TextSelectionManager: NSObject { // MARK: - Selection Views - /// Update all selection cursors. Placing them in the correct position for each text selection and reseting the - /// blink timer. - func updateSelectionViews(force: Bool = false) { + /// Update all selection cursors. Placing them in the correct position for each text selection and + /// optionally reseting the blink timer. + func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) { guard textView?.isFirstResponder ?? false else { return } var didUpdate: Bool = false for textSelection in textSelections { if textSelection.range.isEmpty { - guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else { - continue - } - - var doesViewNeedReposition: Bool - - // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an - // approximate equals in that case to avoid extra updates. - if useSystemCursor, #available(macOS 14.0, *) { - doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) - || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) - } else { - doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin - || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 - } - - if textSelection.view == nil || doesViewNeedReposition { - let cursorView: NSView + didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection) + } else if !textSelection.range.isEmpty && textSelection.view != nil { + textSelection.view?.removeFromSuperview() + textSelection.view = nil + didUpdate = true + } + } - if let existingCursorView = textSelection.view { - cursorView = existingCursorView - } else { - textSelection.view?.removeFromSuperview() - textSelection.view = nil + if didUpdate || force { + delegate?.setNeedsDisplay() + if !skipTimerReset { + cursorTimer.resetTimer() + resetSystemCursorTimers() + } + } + } - if useSystemCursor, #available(macOS 14.0, *) { - let systemCursorView = NSTextInsertionIndicator(frame: .zero) - cursorView = systemCursorView - systemCursorView.displayMode = .automatic - } else { - let internalCursorView = CursorView(color: insertionPointColor) - cursorView = internalCursorView - cursorTimer.register(internalCursorView) - } + private func repositionCursorSelection(textSelection: TextSelection) -> Bool { + guard let cursorRect = layoutManager?.rectForOffset(textSelection.range.location) else { + return false + } - textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) - } + var doesViewNeedReposition: Bool - cursorView.frame.origin = cursorRect.origin - cursorView.frame.size.height = cursorRect.height + // If using the system cursor, macOS will change the origin and height by about 0.5, so we do an + // approximate equals in that case to avoid extra updates. + if useSystemCursor, #available(macOS 14.0, *) { + doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) + || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) + } else { + doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin + || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 + } - textSelection.view = cursorView - textSelection.boundingRect = cursorView.frame + if textSelection.view == nil || doesViewNeedReposition { + let cursorView: NSView - didUpdate = true - } - } else if !textSelection.range.isEmpty && textSelection.view != nil { + if let existingCursorView = textSelection.view { + cursorView = existingCursorView + } else { textSelection.view?.removeFromSuperview() textSelection.view = nil - didUpdate = true + + if useSystemCursor, #available(macOS 14.0, *) { + let systemCursorView = NSTextInsertionIndicator(frame: .zero) + cursorView = systemCursorView + systemCursorView.displayMode = .automatic + } else { + let internalCursorView = CursorView(color: insertionPointColor) + cursorView = internalCursorView + cursorTimer.register(internalCursorView) + } + + textView?.addSubview(cursorView, positioned: .above, relativeTo: nil) } - } - if didUpdate || force { - delegate?.setNeedsDisplay() - cursorTimer.resetTimer() - resetSystemCursorTimers() + cursorView.frame.origin = cursorRect.origin + cursorView.frame.size.height = cursorRect.height + + textSelection.view = cursorView + textSelection.boundingRect = cursorView.frame + + return true } + + return false } private func resetSystemCursorTimers() { 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+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index 186d25dbd..dbd878a3c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -255,10 +255,10 @@ extension TextView: NSDraggingSource { insertText("") // Replace the selected ranges with nothing } - undoManager?.endUndoGrouping() - replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) + undoManager?.endUndoGrouping() + selectionManager.setSelectedRange( NSRange(location: insertionOffset, length: NSString(string: insertionString).length) ) 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..e11187851 100644 --- a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -47,4 +47,16 @@ extension TextView { return false } + + override public func flagsChanged(with event: NSEvent) { + super.flagsChanged(with: event) + + let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let modifierFlagsIsOption = modifierFlags == [.option] + + if modifierFlagsIsOption != isOptionPressed { + isOptionPressed = modifierFlagsIsOption + resetCursorRects() + } + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift index 5a4162252..2fef2aa1b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Layout.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -11,6 +11,7 @@ extension TextView { override public func layout() { super.layout() layoutManager.layoutLines() + selectionManager.updateSelectionViews(skipTimerReset: true) } open override class var isCompatibleWithResponsiveScrolling: Bool { diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index db1a96d1b..636ea472a 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 { @@ -86,50 +87,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() @@ -164,6 +144,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() { @@ -182,4 +164,48 @@ extension TextView { mouseDragTimer?.invalidate() mouseDragTimer = nil } + + // MARK: - Drag Selection + + 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) { + selectColumns(betweenPointA: mouseDragAnchor, pointB: locationInView) + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index 00475ef9f..c4cf1fa6c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -14,19 +14,17 @@ extension TextView { /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. public func scrollSelectionToVisible() { - guard let scrollView, let selection = getSelection() else { + guard let scrollView else { return } - let offsetToScrollTo = offsetNotPivot(selection) - // There's a bit of a chicken-and-the-egg issue going on here. We need to know the rect to scroll to, but we // can't know the exact rect to make visible without laying out the text. Then, once text is laid out the // selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout // pass and scroll to that rect. var lastFrame: CGRect = .zero - while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect { + while let boundingRect = getSelection()?.boundingRect, lastFrame != boundingRect { lastFrame = boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() @@ -34,6 +32,7 @@ extension TextView { } if lastFrame != .zero { scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) } } diff --git a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift index a12f1d830..14d803fba 100644 --- a/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift +++ b/Sources/CodeEditTextView/TextView/TextView+UndoRedo.swift @@ -14,7 +14,7 @@ extension TextView { } override public var undoManager: UndoManager? { - _undoManager?.manager + _undoManager } @objc func undo(_ sender: AnyObject?) { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c9cf7c31..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 } @@ -346,6 +348,8 @@ public class TextView: NSView, NSTextContent { selectionManager = setUpSelectionManager() selectionManager.useSystemCursor = useSystemCursor + layoutManager.attachments.setUpSelectionListener(for: selectionManager) + _undoManager = CEUndoManager(textView: self) layoutManager.layoutLines() diff --git a/Sources/CodeEditTextView/Utils/CEUndoManager.swift b/Sources/CodeEditTextView/Utils/CEUndoManager.swift index fd800fcf1..722b188ed 100644 --- a/Sources/CodeEditTextView/Utils/CEUndoManager.swift +++ b/Sources/CodeEditTextView/Utils/CEUndoManager.swift @@ -15,45 +15,7 @@ import TextStory /// - Grouping pasted text /// /// If needed, the automatic undo grouping can be overridden using the `beginGrouping()` and `endGrouping()` methods. -public class CEUndoManager { - /// An `UndoManager` subclass that forwards relevant actions to a `CEUndoManager`. - /// Allows for objects like `TextView` to use the `UndoManager` API - /// while CETV manages the undo/redo actions. - public class DelegatedUndoManager: UndoManager { - weak var parent: CEUndoManager? - - public override var isUndoing: Bool { parent?.isUndoing ?? false } - public override var isRedoing: Bool { parent?.isRedoing ?? false } - public override var canUndo: Bool { parent?.canUndo ?? false } - public override var canRedo: Bool { parent?.canRedo ?? false } - - public func registerMutation(_ mutation: TextMutation) { - parent?.registerMutation(mutation) - removeAllActions() - } - - public override func undo() { - parent?.undo() - } - - public override func redo() { - parent?.redo() - } - - public override func beginUndoGrouping() { - parent?.beginUndoGrouping() - } - - public override func endUndoGrouping() { - parent?.endUndoGrouping() - } - - public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { - // no-op, but just in case to save resources: - removeAllActions() - } - } - +public class CEUndoManager: UndoManager { /// Represents a group of mutations that should be treated as one mutation when undoing/redoing. private struct UndoGroup { var mutations: [Mutation] @@ -65,16 +27,17 @@ public class CEUndoManager { var inverse: TextMutation } - public let manager: DelegatedUndoManager - private(set) public var isUndoing: Bool = false - private(set) public var isRedoing: Bool = false + private var _isUndoing: Bool = false + private var _isRedoing: Bool = false - public var canUndo: Bool { - !undoStack.isEmpty - } - public var canRedo: Bool { - !redoStack.isEmpty - } + override public var isUndoing: Bool { _isUndoing } + override public var isRedoing: Bool { _isRedoing } + + override public var undoCount: Int { undoStack.count } + override public var redoCount: Int { redoStack.count } + + override public var canUndo: Bool { !undoStack.isEmpty } + override public var canRedo: Bool { !redoStack.isEmpty } /// A stack of operations that can be undone. private var undoStack: [UndoGroup] = [] @@ -93,10 +56,7 @@ public class CEUndoManager { // MARK: - Init - public init() { - self.manager = DelegatedUndoManager() - manager.parent = self - } + override public init() { } convenience init(textView: TextView) { self.init() @@ -106,37 +66,78 @@ public class CEUndoManager { // MARK: - Undo/Redo /// Performs an undo operation if there is one available. - public func undo() { - guard !isDisabled, let item = undoStack.popLast(), let textView else { + override public func undo() { + guard !isDisabled, let textView else { return } - isUndoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self.manager) + + guard let item = undoStack.popLast() else { + NSSound.beep() + return + } + + _isUndoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillUndoChange, object: self) textView.textStorage.beginEditing() for mutation in item.mutations.reversed() { textView.replaceCharacters(in: mutation.inverse.range, with: mutation.inverse.string) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self.manager) + + updateSelectionsForMutations(mutations: item.mutations.map { $0.mutation }) + textView.scrollSelectionToVisible() + + NotificationCenter.default.post(name: .NSUndoManagerDidUndoChange, object: self) redoStack.append(item) - isUndoing = false + _isUndoing = false } /// Performs a redo operation if there is one available. - public func redo() { - guard !isDisabled, let item = redoStack.popLast(), let textView else { + override public func redo() { + guard !isDisabled, let textView else { return } - isRedoing = true - NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self.manager) + + guard let item = redoStack.popLast() else { + NSSound.beep() + return + } + + _isRedoing = true + NotificationCenter.default.post(name: .NSUndoManagerWillRedoChange, object: self) + textView.selectionManager.removeCursors() textView.textStorage.beginEditing() for mutation in item.mutations { textView.replaceCharacters(in: mutation.mutation.range, with: mutation.mutation.string) } textView.textStorage.endEditing() - NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self.manager) + + updateSelectionsForMutations(mutations: item.mutations.map { $0.inverse }) + textView.scrollSelectionToVisible() + + NotificationCenter.default.post(name: .NSUndoManagerDidRedoChange, object: self) undoStack.append(item) - isRedoing = false + _isRedoing = false + } + + /// We often undo/redo a group of mutations that contain updated ranges that are next to each other but for a user + /// should be one continuous range. This merges those ranges into a set of disjoint ranges before updating the + /// selection manager. + private func updateSelectionsForMutations(mutations: [TextMutation]) { + if mutations.reduce(0, { $0 + $1.range.length }) == 0 { + if let minimumMutation = mutations.min(by: { $0.range.location < $1.range.location }) { + // If the mutations are only deleting text (no replacement), we just place the cursor at the last range, + // since all the ranges are the same but the other method will return no ranges (empty range). + textView?.selectionManager.setSelectedRange( + NSRange(location: minimumMutation.range.location, length: 0) + ) + } + } else { + let mergedRanges = mutations.reduce(into: IndexSet(), { set, mutation in + set.insert(range: mutation.range) + }) + textView?.selectionManager.setSelectedRanges(mergedRanges.rangeView.map { NSRange($0) }) + } } /// Clears the undo/redo stacks. @@ -147,11 +148,17 @@ public class CEUndoManager { // MARK: - Mutations + public override func registerUndo(withTarget target: Any, selector: Selector, object anObject: Any?) { + // no-op, but just in case to save resources: + removeAllActions() + } + /// Registers a mutation into the undo stack. /// /// Calling this method while the manager is in an undo/redo operation will result in a no-op. /// - Parameter mutation: The mutation to register for undo/redo public func registerMutation(_ mutation: TextMutation) { + removeAllActions() guard let textView, let textStorage = textView.textStorage, !isUndoing, @@ -178,7 +185,7 @@ public class CEUndoManager { // MARK: - Grouping /// Groups all incoming mutations. - public func beginUndoGrouping() { + override public func beginUndoGrouping() { guard !isGrouping else { return } isGrouping = true // This is a new undo group, break for it. @@ -186,7 +193,7 @@ public class CEUndoManager { } /// Stops grouping all incoming mutations. - public func endUndoGrouping() { + override public func endUndoGrouping() { guard isGrouping else { return } isGrouping = false // We just ended a group, do not allow the next mutation to be added to the group we just made. diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift index a3510c608..1841cc5ed 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerAttachmentsTests.swift @@ -108,4 +108,14 @@ struct TextLayoutManagerAttachmentsTests { // Line "5" is from the trailing newline. That shows up as an empty line in the view. #expect(lines.map { $0.index } == [0, 4]) } + + @Test + func addingAttachmentThatMeetsEndOfLineMergesNextLine() throws { + let height = try #require(layoutManager.textLineForOffset(0)).height + layoutManager.attachments.add(DemoTextAttachment(), for: NSRange(start: 0, end: 3)) + + // With bug: the line for offset 3 would be the 2nd line (index 1). They should be merged + #expect(layoutManager.textLineForOffset(0)?.index == 0) + #expect(layoutManager.textLineForOffset(3)?.index == 0) + } } diff --git a/Tests/CodeEditTextViewTests/TextViewTests.swift b/Tests/CodeEditTextViewTests/TextViewTests.swift index d8ac192b1..7b6ba44bb 100644 --- a/Tests/CodeEditTextViewTests/TextViewTests.swift +++ b/Tests/CodeEditTextViewTests/TextViewTests.swift @@ -64,4 +64,18 @@ struct TextViewTests { #expect(textView1.layoutManager.lineCount == 3) #expect(textView2.layoutManager.lineCount == 3) } + + @Test("Custom UndoManager class receives events") + func customUndoManagerReceivesEvents() { + let textView = TextView(string: "") + + textView.replaceCharacters(in: .zero, with: "Hello World") + textView.undo(nil) + + #expect(textView.string == "") + + textView.redo(nil) + + #expect(textView.string == "Hello World") + } } diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index e065cb69c..d671ea6ff 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -3,6 +3,7 @@ import XCTest final class DemoTextAttachment: TextAttachment { var width: CGFloat + var isSelected: Bool = false init(width: CGFloat = 100) { self.width = width