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/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index 90cf4eff3..f53e14f85 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 @@ -263,6 +269,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/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/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 2afe9a93f..3e042225c 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 3a62e2b9d..f4ace5689 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. @@ -40,6 +41,43 @@ public final class LineFragment: Identifiable, Equatable { 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 { + return CTLineGetOffsetForStringIndex(ctLine, offset, nil) + } + + public func draw(in context: CGContext, yPos: CGFloat) { + context.saveGState() + + // Removes jagged edges + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + // 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) + + 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() + } + /// Calculates the drawing rect for a given range. /// - Parameter range: The range to calculate the bounds for, relative to the line. /// - Returns: A rect that contains the text contents in the given range. diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index ff5625d23..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,32 +39,6 @@ final class LineFragmentView: NSView { guard let lineFragment, let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - - // Removes jagged edges - context.setAllowsAntialiasing(true) - context.setShouldAntialias(true) - - // 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) - - 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 ae25b55bb..453fcac87 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -139,17 +139,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 } @@ -175,8 +177,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 diff --git a/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift new file mode 100644 index 000000000..03fc773e4 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/DraggingTextRenderer.swift @@ -0,0 +1,109 @@ +// +// 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") + + var minY: CGFloat = .infinity + var maxY: CGFloat = 0.0 + + for range in ranges { + for line in layoutManager.lineStorage.linesInRange(range) { + 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..186d25dbd 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Drag.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Drag.swift @@ -5,10 +5,16 @@ // Created by Khan Winter on 10/20/23. // +import Foundation import AppKit +private let pasteboardObjects = [NSString.self, NSURL.self] + extension TextView: NSDraggingSource { - class DragSelectionGesture: NSPressGestureRecognizer { + // 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 { return @@ -26,6 +32,8 @@ 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 @@ -33,47 +41,228 @@ extension TextView: NSDraggingSource { addGestureRecognizer(dragGesture) } - @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 - ) + /// 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 { + sender.state = .ended + } - guard let bitmap = bitmapImageRepForCachingDisplay(in: imageBounds) else { + guard let visibleTextRange, + let draggingView = DraggingTextRenderer( + ranges: selectionManager.textSelections + .sorted(using: KeyPathComparator(\.range.location)) + .compactMap { $0.range.intersection(visibleTextRange) }, + layoutManager: layoutManager + ) else { return } - selectionRects.forEach { selectionRect in - self.cacheDisplay(in: selectionRect, to: bitmap) + guard let bitmap = bitmapImageRepForCachingDisplay(in: draggingView.frame) else { + return + } + + 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 }) + let 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) + + guard let currentEvent = NSApp.currentEvent else { + return + } - beginDraggingSession(with: [draggingItem], event: NSApp.currentEvent!, source: self) + 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() + } + + /// 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 + } + + 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 { + 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) + + guard canReadObjects else { + return NSDragOperation() + } + + if let currentEvent = NSApplication.shared.currentEvent, currentEvent.modifierFlags.contains(.option) { + return .copy + } + + 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 + 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 + } + + let shouldCutSourceText = !(NSApplication.shared.currentEvent?.modifierFlags.contains(.option) ?? false) + + undoManager?.beginUndoGrouping() + + 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() + 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 + } + + undoManager?.endUndoGrouping() + + 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+Lifecycle.swift b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift new file mode 100644 index 000000000..af07527fe --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+Lifecycle.swift @@ -0,0 +1,33 @@ +// +// TextView+Lifecycle.swift +// CodeEditTextView +// +// Created by Khan Winter on 4/7/25. +// + +import AppKit + +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 dc752e7c7..db1a96d1b 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 @@ -84,13 +77,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 } @@ -171,4 +163,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 abeb112ff..85b2e11b0 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -245,8 +245,10 @@ public class TextView: NSView, NSTextContent { /// layout system. Use methods like ``TextView/replaceCharacters(in:with:)-58mt7`` or /// ``TextView/insertText(_:)`` to modify content. package(set) public var textStorage: NSTextStorage! + /// The layout manager for the text view. package(set) public var layoutManager: TextLayoutManager! + /// The selection manager for the text view. package(set) public var selectionManager: TextSelectionManager! @@ -256,15 +258,24 @@ public class TextView: NSView, NSTextContent { // 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? var cursorSelectionMode: CursorSelectionMode = .character + /// 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? { @@ -316,6 +327,7 @@ public class TextView: NSView, NSTextContent { postsFrameChangedNotifications = true postsBoundsChangedNotifications = true autoresizingMask = [.width, .height] + registerForDraggedTypes([.string, .fileContents, .html, .multipleTextSelection, .tabularText, .rtf]) self.typingAttributes = [ .font: font, @@ -344,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.