From db2d940b767e6fba1be32c313319a242450d9df8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 24 Nov 2024 10:21:08 -0600 Subject: [PATCH 01/14] Inset Selection Rect By Leading Edge --- .../TextSelectionManager/TextSelectionManager+FillRects.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 5b3b9a2b5..275352590 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -38,7 +38,7 @@ extension TextSelectionManager { if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { fillRects.append(CGRect( - x: rect.minX, + x: max(layoutManager.edgeInsets.left, rect.minX), y: firstLinePosition.yPos + firstLinePosition.height, width: rect.width, height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) From 7f51e85218d831c02a62dcb9dda65c7d31460c5a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sun, 24 Nov 2024 10:40:24 -0600 Subject: [PATCH 02/14] Handle Right Edge Insets --- .../TextSelectionManager+FillRects.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 275352590..7e6a2362e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -8,7 +8,11 @@ import Foundation extension TextSelectionManager { - /// Calculate a set of rects for a text selection suitable for highlighting the selection. + /// Calculate a set of rects for a text selection suitable for filling with the selection color to indicate a multi-line selection. + /// + /// The returned rects are inset by edge insets passed to the text view, the given `rect` parameter can be the 'raw' + /// rect to draw in, no need to inset it before this method call. + /// /// - Parameters: /// - rect: The bounding rect of available draw space. /// - textSelection: The selection to use. @@ -25,27 +29,35 @@ extension TextSelectionManager { return [] } + let insetXPos = max(layoutManager.edgeInsets.left, rect.minX) + let insetWidth = max(0, rect.maxX - insetXPos - layoutManager.edgeInsets.right) + let insetRect = NSRect(x: insetXPos, y: rect.origin.y, width: insetWidth, height: rect.height) + // Calculate the first line and any rects selected // If the last line position is not the same as the first, calculate any rects from that line. // If there's > 0 space between the first and last positions, add a rect between them to cover any // intermediate lines. - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition)) - - if lastLinePosition.range != firstLinePosition.range { - fillRects.append(contentsOf: getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition)) + let firstLineRects = getFillRects(in: rect, selectionRange: range, forPosition: firstLinePosition) + let lastLineRects: [CGRect] = if lastLinePosition.range != firstLinePosition.range { + getFillRects(in: rect, selectionRange: range, forPosition: lastLinePosition) + } else { + [] } + fillRects.append(contentsOf: firstLineRects + lastLineRects) + if firstLinePosition.yPos + firstLinePosition.height < lastLinePosition.yPos { fillRects.append(CGRect( - x: max(layoutManager.edgeInsets.left, rect.minX), + x: insetXPos, y: firstLinePosition.yPos + firstLinePosition.height, - width: rect.width, + width: insetWidth, height: lastLinePosition.yPos - (firstLinePosition.yPos + firstLinePosition.height) )) } - return fillRects + // Pixel align these to avoid aliasing on the edges of each rect that should be a solid box. + return fillRects.map { $0.intersection(insetRect).pixelAligned } } /// Find fill rects for a specific line position. From 350dffc784199fc9cea86fdae228471a64361a15 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 9 Jan 2025 21:13:23 -0600 Subject: [PATCH 03/14] Start Implementing Drawing View --- .../Cursors/CursorManager.swift | 8 + .../TextLayoutManager+Public.swift | 8 + .../TextLayoutManager/TextLayoutManager.swift | 2 +- .../TextLine/LineFragment.swift | 33 ++++ .../TextLine/LineFragmentView.swift | 23 +-- .../TextSelectionManager.swift | 28 +-- .../TextView/DraggingTextRenderer.swift | 115 ++++++++++++ .../TextView/TextView+Drag.swift | 176 +++++++++++++++--- .../TextView/TextView+Drop.swift | 12 ++ .../TextView/TextView+Mouse.swift | 33 ++-- .../CodeEditTextView/TextView/TextView.swift | 12 ++ 11 files changed, 372 insertions(+), 78 deletions(-) create mode 100644 Sources/CodeEditTextView/Cursors/CursorManager.swift create mode 100644 Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+Drop.swift diff --git a/Sources/CodeEditTextView/Cursors/CursorManager.swift b/Sources/CodeEditTextView/Cursors/CursorManager.swift new file mode 100644 index 000000000..a285a5005 --- /dev/null +++ b/Sources/CodeEditTextView/Cursors/CursorManager.swift @@ -0,0 +1,8 @@ +// +// File.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/25/24. +// + +import Foundation diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 938950ac8..ef1cda0c8 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -8,6 +8,8 @@ import AppKit extension TextLayoutManager { + // MARK: - Estimate + public func estimatedHeight() -> CGFloat { max(lineStorage.height, estimateLineHeight()) } @@ -16,6 +18,8 @@ extension TextLayoutManager { maxLineWidth + edgeInsets.horizontal } + // MARK: - Text Lines + /// Finds a text line for the given y position relative to the text view. /// /// Y values begin at the top of the view and extend down. Eg, a `0` y value would return the first line in @@ -101,6 +105,8 @@ extension TextLayoutManager { } } + // MARK: - Rect For Offset + /// Find a position for the character at a given offset. /// Returns the rect of the character at the given offset. /// The rect may represent more than one unicode unit, for instance if the offset is at the beginning of an @@ -175,6 +181,8 @@ extension TextLayoutManager { return nil } + // MARK: - Ensure Layout + /// Forces layout calculation for all lines up to and including the given offset. /// - Parameter offset: The offset to ensure layout until. public func ensureLayoutUntil(_ offset: Int) { diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 7e0e56067..a5592df51 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -70,7 +70,7 @@ public class TextLayoutManager: NSObject { weak var textStorage: NSTextStorage? var lineStorage: TextLineStorage = TextLineStorage() var markedTextManager: MarkedTextManager = MarkedTextManager() - private let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() + let viewReuseQueue: ViewReuseQueue = ViewReuseQueue() package var visibleLineIds: Set = [] /// Used to force a complete re-layout using `setNeedsLayout` package var needsLayout: Bool = false diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index ebe6db74b..625137045 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -6,6 +6,7 @@ // import AppKit +import CodeEditTextViewObjC /// A ``LineFragment`` represents a subrange of characters in a line. Every text line contains at least one line /// fragments, and any lines that need to be broken due to width constraints will contain more than one fragment. @@ -39,4 +40,36 @@ public final class LineFragment: Identifiable, Equatable { public static func == (lhs: LineFragment, rhs: LineFragment) -> Bool { lhs.id == rhs.id } + + /// Finds the x position of the offset in the string the fragment represents. + /// - Parameter offset: The offset, relative to the start of the *line*. + /// - Returns: The x position of the character in the drawn line, from the left. + public func xPos(for offset: Int) -> CGFloat { + let lineRange = CTLineGetStringRange(ctLine) + return CTLineGetOffsetForStringIndex(ctLine, offset, nil) + } + + public func draw(in context: CGContext, yPos: CGFloat) { + context.saveGState() + + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + context.setAllowsFontSmoothing(false) + context.setShouldSmoothFonts(false) + context.setAllowsFontSubpixelPositioning(true) + context.setShouldSubpixelPositionFonts(true) + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + ContextSetHiddenSmoothingStyle(context, 16) + + context.textMatrix = .init(scaleX: 1, y: -1) + context.textPosition = CGPoint( + x: 0, + y: yPos + (height - descent + (heightDifference/2)) + ).pixelAligned + + CTLineDraw(ctLine, context) + context.restoreGState() + } } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 043c1829e..3536fd4e0 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -6,7 +6,6 @@ // import AppKit -import CodeEditTextViewObjC /// Displays a line fragment. final class LineFragmentView: NSView { @@ -40,26 +39,6 @@ final class LineFragmentView: NSView { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setShouldSmoothFonts(false) - context.setAllowsFontSubpixelPositioning(true) - context.setShouldSubpixelPositionFonts(true) - context.setAllowsFontSubpixelQuantization(true) - context.setShouldSubpixelQuantizeFonts(true) - - ContextSetHiddenSmoothingStyle(context, 16) - - context.textMatrix = .init(scaleX: 1, y: -1) - context.textPosition = CGPoint( - x: 0, - y: lineFragment.height - lineFragment.descent + (lineFragment.heightDifference/2) - ).pixelAligned - - CTLineDraw(lineFragment.ctLine, context) - context.restoreGState() + lineFragment.draw(in: context, yPos: 0.0) } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 546e856ff..f103d971e 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -134,17 +134,19 @@ public class TextSelectionManager: NSObject { for textSelection in textSelections { if textSelection.range.isEmpty { - let cursorOrigin = (layoutManager?.rectForOffset(textSelection.range.location) ?? .zero).origin + 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(cursorOrigin) + doesViewNeedReposition = !textSelection.boundingRect.origin.approxEqual(cursorRect.origin) || !textSelection.boundingRect.height.approxEqual(layoutManager?.estimateLineHeight() ?? 0) } else { - doesViewNeedReposition = textSelection.boundingRect.origin != cursorOrigin + doesViewNeedReposition = textSelection.boundingRect.origin != cursorRect.origin || textSelection.boundingRect.height != layoutManager?.estimateLineHeight() ?? 0 } @@ -170,8 +172,8 @@ public class TextSelectionManager: NSObject { textView?.addSubview(cursorView) } - cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0 + cursorView.frame.origin = cursorRect.origin + cursorView.frame.size.height = cursorRect.height textSelection.view = cursorView textSelection.boundingRect = cursorView.frame @@ -201,22 +203,6 @@ public class TextSelectionManager: NSObject { } } - /// Get the height for a cursor placed at the beginning of the given range. - /// - Parameter range: The range the cursor is at. - /// - Returns: The height the cursor should be to match the text at that location. - fileprivate func heightForCursorAt(_ range: NSRange) -> CGFloat? { - guard let selectedLine = layoutManager?.textLineForOffset(range.location) else { - return layoutManager?.estimateLineHeight() - } - return selectedLine - .data - .lineFragments - .getLine(atOffset: range.location - (selectedLine.range.location))? - .height - ?? layoutManager?.estimateLineHeight() - - } - /// Removes all cursor views and stops the cursor blink timer. func removeCursors() { cursorTimer.stopTimer() diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift new file mode 100644 index 000000000..e507f8b29 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -0,0 +1,115 @@ +// +// DraggingTextRenderer.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/24/24. +// + +import AppKit + +class DraggingTextRenderer: NSView { + let ranges: [NSRange] + let layoutManager: TextLayoutManager + + override var isFlipped: Bool { + true + } + + override var intrinsicContentSize: NSSize { + self.frame.size + } + + init?(ranges: [NSRange], layoutManager: TextLayoutManager) { + self.ranges = ranges + self.layoutManager = layoutManager + + assert(!ranges.isEmpty, "Empty ranges not allowed") + + guard let lastRange = ranges.last else { return nil } + + var minY: CGFloat = .infinity + var maxY: CGFloat = 0.0 + + for range in ranges { + for line in layoutManager.lineStorage.linesInRange(range) { + guard layoutManager.visibleLineIds.contains(line.data.id), // Only grab visible text + let width = line.data.maxWidth else { + break + } + minY = min(minY, line.yPos) + maxY = max(maxY, line.yPos + line.height) + } + } + + let frame = CGRect( + x: layoutManager.edgeInsets.left, + y: minY, + width: layoutManager.maxLineWidth, + height: maxY - minY + ) + + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let context = NSGraphicsContext.current?.cgContext, + let firstRange = ranges.first, + let minRect = layoutManager.rectForOffset(firstRange.lowerBound) else { + return + } + + for range in ranges { + for line in layoutManager.lineStorage.linesInRange(range) { + drawLine(line, in: range, yOffset: minRect.minY, context: context) + } + } + } + + private func drawLine( + _ line: TextLineStorage.TextLinePosition, + in selectedRange: NSRange, + yOffset: CGFloat, + context: CGContext + ) { + for fragment in line.data.lineFragments { + guard let fragmentRange = fragment.range.shifted(by: line.range.location), + fragmentRange.intersection(selectedRange) != nil else { + continue + } + let fragmentYPos = line.yPos + fragment.yPos - yOffset + fragment.data.draw(in: context, yPos: fragmentYPos) + + // Clear text that's not selected + if fragmentRange.contains(selectedRange.lowerBound) { + let relativeOffset = selectedRange.lowerBound - line.range.lowerBound + let selectionXPos = fragment.data.xPos(for: relativeOffset) + context.clear( + CGRect( + x: 0.0, + y: fragmentYPos, + width: selectionXPos, + height: fragment.height + ).pixelAligned + ) + } + + if fragmentRange.contains(selectedRange.upperBound) { + let relativeOffset = selectedRange.upperBound - line.range.lowerBound + let selectionXPos = fragment.data.xPos(for: relativeOffset) + context.clear( + CGRect( + x: selectionXPos, + y: fragmentYPos, + width: frame.width - selectionXPos, + height: fragment.height + ).pixelAligned + ) + } + } + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index f4ac08e84..5b7ff39cf 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -5,10 +5,15 @@ // Created by Khan Winter on 10/20/23. // +import Foundation import AppKit +fileprivate let pasteboardObjects = [NSString.self, NSURL.self] + extension TextView: NSDraggingSource { - class DragSelectionGesture: NSPressGestureRecognizer { + // MARK: - Drag Gesture + + private class DragSelectionGesture: NSPressGestureRecognizer { override func mouseDown(with event: NSEvent) { guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else { return @@ -34,46 +39,171 @@ extension TextView: NSDraggingSource { } @objc private func dragGestureHandler(_ sender: Any) { - let selectionRects = selectionManager.textSelections.filter({ !$0.range.isEmpty }).flatMap { - selectionManager.getFillRects(in: frame, for: $0) - } - // TODO: This SUcks - let minX = selectionRects.min(by: { $0.minX < $1.minX })?.minX ?? 0.0 - let minY = selectionRects.min(by: { $0.minY < $1.minY })?.minY ?? 0.0 - let maxX = selectionRects.max(by: { $0.maxX < $1.maxX })?.maxX ?? 0.0 - let maxY = selectionRects.max(by: { $0.maxY < $1.maxY })?.maxY ?? 0.0 - let imageBounds = CGRect( - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY - ) + guard let visibleTextRange, + let draggingView = DraggingTextRenderer( + ranges: selectionManager.textSelections + .sorted(using: KeyPathComparator(\.range.location)) + .compactMap { $0.range.intersection(visibleTextRange) }, + layoutManager: layoutManager + ) else { + return + } - guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else { + guard let bitmap = bitmapImageRepForCachingDisplay(in: draggingView.frame) else { return } - selectionRects.forEach { selectionRect in - self.cacheDisplay(in: selectionRect, to: bitmap) + draggingView.cacheDisplay(in: draggingView.bounds, to: bitmap) + + guard let cgImage = bitmap.cgImage else { + return } - let draggingImage = NSImage(cgImage: bitmap.cgImage!, size: imageBounds.size) + let draggingImage = NSImage(cgImage: cgImage, size: draggingView.intrinsicContentSize) - let attributedString = selectionManager + let attributedStrings = selectionManager .textSelections .sorted(by: { $0.range.location < $1.range.location }) .map { textStorage.attributedSubstring(from: $0.range) } - .reduce(NSMutableAttributedString(), { $0.append($1); return $0 }) + var attributedString = NSMutableAttributedString() + for (idx, string) in attributedStrings.enumerated() { + attributedString.append(string) + if idx < attributedStrings.count - 1 { + attributedString.append(NSAttributedString(string: layoutManager.detectedLineEnding.rawValue)) + } + } + let draggingItem = NSDraggingItem(pasteboardWriter: attributedString) - draggingItem.setDraggingFrame(imageBounds, contents: draggingImage) + draggingItem.setDraggingFrame(draggingView.frame, contents: draggingImage) - beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) + guard let currentEvent = NSApp.currentEvent else { + return + } + + beginDraggingSession(with: [draggingItem], event: currentEvent, source: self) } + // MARK: - NSDraggingSource + public func draggingSession( _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext ) -> NSDragOperation { context == .outsideApplication ? .copy : .move } + + public func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + if let draggingCursorView { + draggingCursorView.removeFromSuperview() + self.draggingCursorView = nil + } + isDragging = true + setUpMouseAutoscrollTimer() + } + + public func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { + guard let windowCoordinates = self.window?.convertPoint(fromScreen: screenPoint) else { + return + } + + let viewPoint = self.convert(windowCoordinates, from: nil) // Converts from window + let cursor: NSView + + if let draggingCursorView { + cursor = draggingCursorView + } else if useSystemCursor, #available(macOS 15, *) { + let systemCursor = NSTextInsertionIndicator() + cursor = systemCursor + systemCursor.displayMode = .visible + addSubview(cursor) + } else { + cursor = CursorView(color: selectionManager.insertionPointColor) + addSubview(cursor) + } + + self.draggingCursorView = cursor + + guard let documentOffset = layoutManager.textOffsetAtPoint(viewPoint), + let cursorPosition = layoutManager.rectForOffset(documentOffset) else { + return + } + + // Don't show a cursor in selected areas + guard !selectionManager.textSelections.contains(where: { $0.range.contains(documentOffset) }) else { + draggingCursorView?.removeFromSuperview() + draggingCursorView = nil + return + } + + cursor.frame.origin = cursorPosition.origin + cursor.frame.size.height = cursorPosition.height + } + + public func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + if let draggingCursorView { + draggingCursorView.removeFromSuperview() + self.draggingCursorView = nil + } + isDragging = false + disableMouseAutoscrollTimer() + } + + override public func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + let canReadObjects = sender.draggingPasteboard.canReadObject(forClasses: pasteboardObjects) + + if canReadObjects { + return .copy + } + + return NSDragOperation() + } + + override public func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + guard let objects = sender.draggingPasteboard.readObjects(forClasses: pasteboardObjects)? + .compactMap({ anyObject in + if let object = anyObject as? NSString { + return String(object) + } else if let object = anyObject as? NSURL, let string = object.absoluteString { + return String(string) + } + return nil + }), + objects.count > 0 else { + return false + } + let insertionString = objects.joined(separator: layoutManager.detectedLineEnding.rawValue) + + // Grab the insertion location + guard let draggingCursorView, + var insertionOffset = layoutManager.textOffsetAtPoint(draggingCursorView.frame.origin) else { + // There was no active drag + return false + } + + if let source = sender.draggingSource as? TextView, source === self { + // Offset the insertion location so that we can remove the text first before pasting it into the editor. + var updatedInsertionOffset = insertionOffset + for selection in source.selectionManager.textSelections.reversed() + where selection.range.location < insertionOffset { + if selection.range.upperBound > insertionOffset { + updatedInsertionOffset -= insertionOffset - selection.range.location + } else { + updatedInsertionOffset -= selection.range.length + } + } + insertionOffset = updatedInsertionOffset + insertText("") // Replace the selected ranges with nothing + } + + replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) + selectionManager.setSelectedRange( + NSRange(location: insertionOffset, length: NSString(string: insertionString).length) + ) + + return true + } } diff --git a/Sources/CodeEditTextView/TextView/TextView+Drop.swift b/Sources/CodeEditTextView/TextView/TextView+Drop.swift new file mode 100644 index 000000000..33670b127 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+Drop.swift @@ -0,0 +1,12 @@ +// +// TextView+DragDestination.swift +// CodeEditTextView +// +// Created by Khan Winter on 11/25/24. +// + +import AppKit + +extension TextView { + +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 3a4839fe1..093df93b4 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -28,14 +28,7 @@ extension TextView { break } - mouseDragTimer?.invalidate() - // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) - mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in - if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { - self?.mouseDragged(with: event) - self?.autoscroll(with: event) - } - } + setUpMouseAutoscrollTimer() } /// Single click, if control-shift we add a cursor @@ -78,13 +71,12 @@ extension TextView { override public func mouseUp(with event: NSEvent) { mouseDragAnchor = nil - mouseDragTimer?.invalidate() - mouseDragTimer = nil + disableMouseAutoscrollTimer() super.mouseUp(with: event) } override public func mouseDragged(with event: NSEvent) { - guard !(inputContext?.handleEvent(event) ?? false) && isSelectable else { + guard !(inputContext?.handleEvent(event) ?? false) && isSelectable && !isDragging else { return } @@ -134,4 +126,23 @@ extension TextView { selectionManager.setSelectedRange(selectedRange) setNeedsDisplay() } + + /// Sets up a timer that fires at a predetermined period to autoscroll the text view. + /// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``. + func setUpMouseAutoscrollTimer() { + mouseDragTimer?.invalidate() + // https://cocoadev.github.io/AutoScrolling/ (fired at ~45Hz) + mouseDragTimer = Timer.scheduledTimer(withTimeInterval: 0.022, repeats: true) { [weak self] _ in + if let event = self?.window?.currentEvent, event.type == .leftMouseDragged { + self?.mouseDragged(with: event) + self?.autoscroll(with: event) + } + } + } + + /// Disables the mouse drag timer started by ``setUpMouseAutoscrollTimer`` + func disableMouseAutoscrollTimer() { + mouseDragTimer?.invalidate() + mouseDragTimer = nil + } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 4c6256128..fb9fc1ede 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -220,22 +220,33 @@ public class TextView: NSView, NSTextContent { /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-58mt7`` or /// ``TextView/insertText(_:)`` to modify content. private(set) public var textStorage: NSTextStorage! + /// The layout manager for the text view. private(set) public var layoutManager: TextLayoutManager! + /// The selection manager for the text view. private(set) public var selectionManager: TextSelectionManager! // MARK: - Private Properties var isFirstResponder: Bool = false + + /// When dragging to create a selection, these enable us to scroll the view as the user drags outside the view's + /// bounds. var mouseDragAnchor: CGPoint? var mouseDragTimer: Timer? + /// When we receive a drag operation we add a temporary cursor view not managed by the selection manager. + /// This is the reference to that view, it is cleaned up when a drag ends. + var draggingCursorView: NSView? + var isDragging: Bool = false + private var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width } internal(set) public var _undoManager: CEUndoManager? + @objc dynamic open var allowsUndo: Bool var scrollView: NSScrollView? { @@ -286,6 +297,7 @@ public class TextView: NSView, NSTextContent { postsFrameChangedNotifications = true postsBoundsChangedNotifications = true autoresizingMask = [.width, .height] + registerForDraggedTypes([.string, .fileContents, .html, .multipleTextSelection, .tabularText, .rtf]) self.typingAttributes = [ .font: font, From 6b26bae33ed1d3d64c8baa15744e7b5615e79fba Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:33:25 -0500 Subject: [PATCH 04/14] Use New Drawing Settings --- Sources/CodeEditTextView/TextLine/LineFragment.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 625137045..e530362e4 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -52,12 +52,18 @@ public final class LineFragment: Identifiable, Equatable { public func draw(in context: CGContext, yPos: CGFloat) { context.saveGState() + // Removes jagged edges context.setAllowsAntialiasing(true) context.setShouldAntialias(true) - context.setAllowsFontSmoothing(false) - context.setShouldSmoothFonts(false) + + // Effectively increases the screen resolution by drawing text in each LED color pixel (R, G, or B), rather than + // the triplet of pixels (RGB) for a regular pixel. This can increase text clarity, but loses effectiveness + // in low-contrast settings. context.setAllowsFontSubpixelPositioning(true) context.setShouldSubpixelPositionFonts(true) + + // Quantizes the position of each glyph, resulting in slightly less accurate positioning, and gaining higher + // quality bitmaps and performance. context.setAllowsFontSubpixelQuantization(true) context.setShouldSubpixelQuantizeFonts(true) @@ -66,7 +72,7 @@ public final class LineFragment: Identifiable, Equatable { context.textMatrix = .init(scaleX: 1, y: -1) context.textPosition = CGPoint( x: 0, - y: yPos + (height - descent + (heightDifference/2)) + y: height - descent + (heightDifference/2) ).pixelAligned CTLineDraw(ctLine, context) From 80960b9a8463a172c4081ef917c50f05b1c7cf3d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:08:47 -0500 Subject: [PATCH 05/14] Fix Infinite Rect Bug, Remove Warnings --- .../CodeEditTextViewExampleDocument.swift | 2 +- .../CodeEditTextView/TextLine/LineFragment.swift | 3 +-- .../TextView/DraggingTextRenderer.swift | 6 ------ .../CodeEditTextView/TextView/TextView+Drag.swift | 15 ++++++++++++--- Sources/CodeEditTextView/TextView/TextView.swift | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift index 790a38fbb..c427db932 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Documents/CodeEditTextViewExampleDocument.swift @@ -25,7 +25,7 @@ struct CodeEditTextViewExampleDocument: FileDocument { guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } - text = String(bytes: data, encoding: .utf8) + text = String(bytes: data, encoding: .utf8) ?? "" } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index e530362e4..8e5767973 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -45,7 +45,6 @@ public final class LineFragment: Identifiable, Equatable { /// - Parameter offset: The offset, relative to the start of the *line*. /// - Returns: The x position of the character in the drawn line, from the left. public func xPos(for offset: Int) -> CGFloat { - let lineRange = CTLineGetStringRange(ctLine) return CTLineGetOffsetForStringIndex(ctLine, offset, nil) } @@ -72,7 +71,7 @@ public final class LineFragment: Identifiable, Equatable { context.textMatrix = .init(scaleX: 1, y: -1) context.textPosition = CGPoint( x: 0, - y: height - descent + (heightDifference/2) + y: yPos + height - descent + (heightDifference/2) ).pixelAligned CTLineDraw(ctLine, context) diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift index e507f8b29..03fc773e4 100644 --- a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -25,17 +25,11 @@ class DraggingTextRenderer: NSView { assert(!ranges.isEmpty, "Empty ranges not allowed") - guard let lastRange = ranges.last else { return nil } - var minY: CGFloat = .infinity var maxY: CGFloat = 0.0 for range in ranges { for line in layoutManager.lineStorage.linesInRange(range) { - guard layoutManager.visibleLineIds.contains(line.data.id), // Only grab visible text - let width = line.data.maxWidth else { - break - } minY = min(minY, line.yPos) maxY = max(maxY, line.yPos + line.height) } diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index 5b7ff39cf..e67eed930 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -8,11 +8,12 @@ import Foundation import AppKit -fileprivate let pasteboardObjects = [NSString.self, NSURL.self] +private let pasteboardObjects = [NSString.self, NSURL.self] extension TextView: NSDraggingSource { // MARK: - Drag Gesture + /// Custom press gesture recognizer that fails if it does not click into a selected range. private class DragSelectionGesture: NSPressGestureRecognizer { override func mouseDown(with event: NSEvent) { guard isEnabled, let view = self.view as? TextView, event.type == .leftMouseDown else { @@ -38,7 +39,12 @@ extension TextView: NSDraggingSource { addGestureRecognizer(dragGesture) } - @objc private func dragGestureHandler(_ sender: Any) { + @objc private func dragGestureHandler(_ sender: DragSelectionGesture) { + guard sender.state == .began else { return } + defer { + sender.state = .ended + } + guard let visibleTextRange, let draggingView = DraggingTextRenderer( ranges: selectionManager.textSelections @@ -65,7 +71,7 @@ extension TextView: NSDraggingSource { .textSelections .sorted(by: { $0.range.location < $1.range.location }) .map { textStorage.attributedSubstring(from: $0.range) } - var attributedString = NSMutableAttributedString() + let attributedString = NSMutableAttributedString() for (idx, string) in attributedStrings.enumerated() { attributedString.append(string) if idx < attributedStrings.count - 1 { @@ -184,6 +190,7 @@ extension TextView: NSDraggingSource { return false } + undoManager?.beginUndoGrouping() if let source = sender.draggingSource as? TextView, source === self { // Offset the insertion location so that we can remove the text first before pasting it into the editor. var updatedInsertionOffset = insertionOffset @@ -200,6 +207,8 @@ extension TextView: NSDraggingSource { } 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.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 73cb6cc12..0ea809e19 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -244,7 +244,7 @@ public class TextView: NSView, NSTextContent { /// - Warning: Do not update the text storage object directly. Doing so will very likely break the text view's /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-58mt7`` or /// ``TextView/insertText(_:)`` to modify content. - private(set) public var textStorage: NSTextStorage! + package(set) public var textStorage: NSTextStorage! /// The layout manager for the text view. private(set) public var layoutManager: TextLayoutManager! From 5e61ee642ef9a025f78d4fbf81d00a6d4aea1081 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:12:22 -0500 Subject: [PATCH 06/14] Remove Unnecessary File --- Sources/CodeEditTextView/Cursors/CursorManager.swift | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 Sources/CodeEditTextView/Cursors/CursorManager.swift diff --git a/Sources/CodeEditTextView/Cursors/CursorManager.swift b/Sources/CodeEditTextView/Cursors/CursorManager.swift deleted file mode 100644 index a285a5005..000000000 --- a/Sources/CodeEditTextView/Cursors/CursorManager.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// File.swift -// CodeEditTextView -// -// Created by Khan Winter on 11/25/24. -// - -import Foundation From 65acace2b1ad3abd13841028e7e0977bbe42fe05 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:13:23 -0500 Subject: [PATCH 07/14] Inadvertent Change From Merge --- Sources/CodeEditTextView/TextView/TextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 0ea809e19..29508efab 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -247,7 +247,7 @@ public class TextView: NSView, NSTextContent { package(set) public var textStorage: NSTextStorage! /// The layout manager for the text view. - private(set) public var layoutManager: TextLayoutManager! + package(set) public var layoutManager: TextLayoutManager! /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! From 4d6578485bd011abf21b52f9f595dfcca9a670e9 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:17:34 -0500 Subject: [PATCH 08/14] Remove Unnecessary File --- .../CodeEditTextView/TextView/TextView+Drop.swift | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 Sources/CodeEditTextView/TextView/TextView+Drop.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+Drop.swift b/Sources/CodeEditTextView/TextView/TextView+Drop.swift deleted file mode 100644 index 33670b127..000000000 --- a/Sources/CodeEditTextView/TextView/TextView+Drop.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// TextView+DragDestination.swift -// CodeEditTextView -// -// Created by Khan Winter on 11/25/24. -// - -import AppKit - -extension TextView { - -} From bde058d87e0d71b649a903eb97aa5dc5749ce705 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:28:23 -0500 Subject: [PATCH 09/14] Copy When Option Pressed --- .../TextView/TextView+Drag.swift | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index e67eed930..b5daa959a 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -159,13 +159,25 @@ extension TextView: NSDraggingSource { } override public func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - let canReadObjects = sender.draggingPasteboard.canReadObject(forClasses: pasteboardObjects) + determineDragOperation(sender) + } + + override public func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + determineDragOperation(sender) + } + + private func determineDragOperation(_ dragInfo: any NSDraggingInfo) -> NSDragOperation { + let canReadObjects = dragInfo.draggingPasteboard.canReadObject(forClasses: pasteboardObjects) - if canReadObjects { + guard canReadObjects else { + return NSDragOperation() + } + + if let currentEvent = NSApplication.shared.currentEvent, currentEvent.modifierFlags.contains(.option) { return .copy } - return NSDragOperation() + return .move } override public func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { @@ -190,8 +202,11 @@ extension TextView: NSDraggingSource { return false } + let shouldCutSourceText = !(NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false) + undoManager?.beginUndoGrouping() - if let source = sender.draggingSource as? TextView, source === self { + + if shouldCutSourceText, let source = sender.draggingSource as? TextView, source === self { // Offset the insertion location so that we can remove the text first before pasting it into the editor. var updatedInsertionOffset = insertionOffset for selection in source.selectionManager.textSelections.reversed() From cab7931ef9eed6146d0422c982da16ae0227fb8f Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 15:55:50 -0500 Subject: [PATCH 10/14] Linter --- .../TextView/TextView+Lifecycle.swift | 31 +++++++++++++++++++ .../TextView/TextView+Mouse.swift | 4 +-- .../CodeEditTextView/TextView/TextView.swift | 25 --------------- 3 files changed, 33 insertions(+), 27 deletions(-) create mode 100644 Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift diff --git a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift new file mode 100644 index 000000000..aaa230337 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift @@ -0,0 +1,31 @@ +// +// TextView+Lifecycle.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +extension TextView { + override public func layout() { + layoutManager.layoutLines() + super.layout() + } + + override public func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + layoutManager.layoutLines() + } + + override public func viewWillMove(toSuperview newSuperview: NSView?) { + guard let scrollView = enclosingScrollView else { + return + } + + setUpScrollListeners(scrollView: scrollView) + } + + override public func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + updateFrameIfNeeded() + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift index 970026ced..db1a96d1b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Mouse.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Mouse.swift @@ -163,7 +163,7 @@ extension TextView { selectionManager.setSelectedRange(selectedRange) setNeedsDisplay() } - + /// Sets up a timer that fires at a predetermined period to autoscroll the text view. /// Ensure the timer is disabled using ``disableMouseAutoscrollTimer``. func setUpMouseAutoscrollTimer() { @@ -176,7 +176,7 @@ extension TextView { } } } - + /// Disables the mouse drag timer started by ``setUpMouseAutoscrollTimer`` func disableMouseAutoscrollTimer() { mouseDragTimer?.invalidate() diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 29508efab..85b2e11b0 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -356,31 +356,6 @@ public class TextView: NSView, NSTextContent { NSRange(location: 0, length: textStorage.length) } - // MARK: - View Lifecycle - - override public func layout() { - layoutManager.layoutLines() - super.layout() - } - - override public func viewWillMove(toWindow newWindow: NSWindow?) { - super.viewWillMove(toWindow: newWindow) - layoutManager.layoutLines() - } - - override public func viewWillMove(toSuperview newSuperview: NSView?) { - guard let scrollView = enclosingScrollView else { - return - } - - setUpScrollListeners(scrollView: scrollView) - } - - override public func viewDidEndLiveResize() { - super.viewDidEndLiveResize() - updateFrameIfNeeded() - } - // MARK: - Hit test /// Returns the responding view for a given point. From 060abd06fd1d0f4baee0b40d949a83180c407e95 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 7 Apr 2025 16:01:36 -0500 Subject: [PATCH 11/14] Missing Import --- ...+ensureLayout.swift => TextLayoutManager+ensureLayout.swift} | 2 +- Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) rename Sources/CodeEditTextView/TextLayoutManager/{TextLayoutManger+ensureLayout.swift => TextLayoutManager+ensureLayout.swift} (96%) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift similarity index 96% rename from Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift rename to Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift index 813c29e62..e0e5fa07d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManger+ensureLayout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+ensureLayout.swift @@ -1,5 +1,5 @@ // -// TextLayoutManger+ensureLayout.swift +// TextLayoutManager+ensureLayout.swift // CodeEditTextView // // Created by Khan Winter on 4/7/25. diff --git a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift index aaa230337..af07527fe 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift @@ -5,6 +5,8 @@ // Created by Khan Winter on 4/7/25. // +import AppKit + extension TextView { override public func layout() { layoutManager.layoutLines() From 538b5321a006f5868855bfaef707f33cc0912827 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:30:23 -0500 Subject: [PATCH 12/14] Add Documentation, Adjust Undo Grouping --- .../TextView/TextView+Drag.swift | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index b5daa959a..ced5c2fe4 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -32,13 +32,27 @@ extension TextView: NSDraggingSource { } } + /// Adds a gesture for recognizing selection dragging gestures to the text view. + /// See ``TextView/DragSelectionGesture`` for details. func setUpDragGesture() { let dragGesture = DragSelectionGesture(target: self, action: #selector(dragGestureHandler(_:))) dragGesture.minimumPressDuration = NSEvent.doubleClickInterval / 3 dragGesture.isEnabled = isSelectable addGestureRecognizer(dragGesture) } - + + /// Handles state change on the drag and drop gesture recognizer. + /// + /// This will ignore any gesture state besides `.began`, and will end by setting the state to `.ended`. The gesture + /// is only meant to handle *recognizing* the drag, but the system drag interaction handles the rest. + /// + /// This will create a ``DraggingTextRenderer`` with the contents of the visible text selection. That is converted + /// into an image and given to a new dragging session on the text view + /// + /// The rest of the drag interaction is handled by ``performDragOperation(_:)``, ``draggingUpdated(_:)``, + /// ``draggingSession(_:willBeginAt:)`` and family. + /// + /// - Parameter sender: The gesture that's sending the state change. @objc private func dragGestureHandler(_ sender: DragSelectionGesture) { guard sender.state == .began else { return } defer { @@ -180,6 +194,18 @@ extension TextView: NSDraggingSource { return .move } + // MARK: - Perform Drag + + /// Performs the final drop operation. + /// + /// This method accepts a number of items from the dragging info's pasteboard, and cuts them into the + /// destination determined by the ``TextView/draggingCursorView``. + /// + /// If the app's current event has the `option` key pressed, this will only paste the text from the pasteboard, + /// and not remove the original dragged text. + /// + /// - Parameter sender: The dragging info to use. + /// - Returns: `true`, if the drag was accepted. override public func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { guard let objects = sender.draggingPasteboard.readObjects(forClasses: pasteboardObjects)? .compactMap({ anyObject in @@ -221,9 +247,10 @@ extension TextView: NSDraggingSource { insertText("") // Replace the selected ranges with nothing } - replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) undoManager?.endUndoGrouping() + replaceCharacters(in: [NSRange(location: insertionOffset, length: 0)], with: insertionString) + selectionManager.setSelectedRange( NSRange(location: insertionOffset, length: NSString(string: insertionString).length) ) From 14ac0195e7fd91cd09c099d20e671015ad2115a6 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:33:07 -0500 Subject: [PATCH 13/14] More Documentation --- Sources/CodeEditTextView/TextView/TextView+Drag.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index ced5c2fe4..ae0cc7f4b 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -120,7 +120,15 @@ extension TextView: NSDraggingSource { isDragging = true setUpMouseAutoscrollTimer() } - + + /// Updates the text view about a dragging session. The text view will update the ``TextView/draggingCursorView`` + /// cursor to match the drop destination depending on where the drag is on the text view. + /// + /// The text view will not place a dragging cursor view when the dragging destination is in an existing + /// text selection. + /// - Parameters: + /// - session: The dragging session that was updated. + /// - screenPoint: The position on the screen where the drag exists. public func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { guard let windowCoordinates = self.window?.convertPoint(fromScreen: screenPoint) else { return From c7441bc39aa45647bcaae84f2a59ab30f2fc653c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 8 Apr 2025 09:33:26 -0500 Subject: [PATCH 14/14] Fix Linter --- Sources/CodeEditTextView/TextView/TextView+Drag.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Drag.swift b/Sources/CodeEditTextView/TextView/TextView+Drag.swift index ae0cc7f4b..186d25dbd 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -40,7 +40,7 @@ extension TextView: NSDraggingSource { dragGesture.isEnabled = isSelectable addGestureRecognizer(dragGesture) } - + /// Handles state change on the drag and drop gesture recognizer. /// /// This will ignore any gesture state besides `.began`, and will end by setting the state to `.ended`. The gesture @@ -120,7 +120,7 @@ extension TextView: NSDraggingSource { isDragging = true setUpMouseAutoscrollTimer() } - + /// Updates the text view about a dragging session. The text view will update the ``TextView/draggingCursorView`` /// cursor to match the drop destination depending on where the drag is on the text view. /// @@ -203,7 +203,7 @@ extension TextView: NSDraggingSource { } // MARK: - Perform Drag - + /// Performs the final drop operation. /// /// This method accepts a number of items from the dragging info's pasteboard, and cuts them into the