diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index d713620a..8bd09bd6 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -189,9 +189,9 @@ public class TextLayoutManager: NSObject { // MARK: - Layout /// Lays out all visible lines - func layoutLines() { // swiftlint:disable:this function_body_length + func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length guard layoutView?.superview != nil, - let visibleRect = delegate?.visibleRect, + let visibleRect = rect ?? delegate?.visibleRect, !isInTransaction, let textStorage else { return diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift index 5c4c54e9..fd2ade30 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift @@ -37,7 +37,7 @@ package extension TextSelectionManager { case .word, .line, .visualLine: return extendSelectionVerticalLine(from: offset, up: up) case .page: - return extendSelectionPage(from: offset, delta: up ? 1 : -1) + return extendSelectionPage(from: offset, delta: up ? 1 : -1, suggestedXPos: suggestedXPos) case .document: if up { return NSRange(location: 0, length: offset) @@ -61,7 +61,7 @@ package extension TextSelectionManager { guard let point = layoutManager?.rectForOffset(offset)?.origin, let newOffset = layoutManager?.textOffsetAtPoint( CGPoint( - x: suggestedXPos == nil ? point.x : suggestedXPos!, + x: suggestedXPos ?? point.x, y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) ) ) else { @@ -120,17 +120,29 @@ package extension TextSelectionManager { /// - offset: The location to start extending the selection from. /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. /// - Returns: The range of the extended selection. - private func extendSelectionPage(from offset: Int, delta: Int) -> NSRange { - guard let textView, let endOffset = layoutManager?.textOffsetAtPoint( - CGPoint( - x: delta > 0 ? textView.frame.maxX : textView.frame.minX, - y: delta > 0 ? textView.frame.maxY : textView.frame.minY - ) - ) else { + private func extendSelectionPage(from offset: Int, delta: Int, suggestedXPos: CGFloat?) -> NSRange { + guard let textView = textView, + let layoutManager, + let currentYPos = layoutManager.rectForOffset(offset)?.origin.y else { + return NSRange(location: offset, length: 0) + } + + let pageHeight = textView.visibleRect.height + + // Grab the line where the next selection should be. Then use the suggestedXPos to find where in the line the + // selection should be extended to. + layoutManager.layoutLines(in: NSRect(x: 0, y: currentYPos, width: layoutManager.maxLineWidth, height: pageHeight)) + guard let nextPageOffset = layoutManager.textOffsetAtPoint(CGPoint( + x: suggestedXPos ?? 0, + y: min(textView.frame.height, max(0, currentYPos + (delta > 0 ? -pageHeight : pageHeight))) + )) else { return NSRange(location: offset, length: 0) } - return endOffset > offset - ? NSRange(location: offset, length: endOffset - offset) - : NSRange(location: endOffset, length: offset - endOffset) + + if delta > 0 { + return NSRange(location: nextPageOffset, length: offset - nextPageOffset) + } else { + return NSRange(location: offset, length: nextPageOffset - offset) + } } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 889ab818..bcd2ddea 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -322,10 +322,12 @@ public class TextSelectionManager: NSObject { let fillRects = getFillRects(in: rect, for: textSelection) - let min = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin ?? .zero - let max = fillRects.max(by: { $0.origin.y < $1.origin.y }) ?? .zero - let size = CGSize(width: max.maxX - min.x, height: max.maxY - min.y) - textSelection.boundingRect = CGRect(origin: min, size: size) + let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 + let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 + let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero + let origin = CGPoint(x: minX, y: minY) + let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) + textSelection.boundingRect = CGRect(origin: origin, size: size) context.fill(fillRects) context.restoreGState() diff --git a/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift new file mode 100644 index 00000000..39588a26 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift @@ -0,0 +1,57 @@ +// +// TextView+FirstResponder.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/15/24. +// + +import AppKit + +extension TextView { + open override func becomeFirstResponder() -> Bool { + isFirstResponder = true + selectionManager.cursorTimer.resetTimer() + needsDisplay = true + return super.becomeFirstResponder() + } + + open override func resignFirstResponder() -> Bool { + isFirstResponder = false + selectionManager.removeCursors() + needsDisplay = true + return super.resignFirstResponder() + } + + open override var canBecomeKeyView: Bool { + super.canBecomeKeyView && acceptsFirstResponder && !isHiddenOrHasHiddenAncestor + } + + /// Sent to the window's first responder when `NSWindow.makeKey()` occurs. + @objc private func becomeKeyWindow() { + _ = becomeFirstResponder() + } + + /// Sent to the window's first responder when `NSWindow.resignKey()` occurs. + @objc private func resignKeyWindow() { + _ = resignFirstResponder() + } + + open override var needsPanelToBecomeKey: Bool { + isSelectable || isEditable + } + + open override var acceptsFirstResponder: Bool { + isSelectable + } + + open override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + return true + } + + open override func resetCursorRects() { + super.resetCursorRects() + if isSelectable { + addCursorRect(visibleRect, cursor: .iBeam) + } + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift new file mode 100644 index 00000000..1ef36d4f --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+KeyDown.swift @@ -0,0 +1,50 @@ +// +// TextView+KeyDown.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/15/24. +// + +import AppKit +import Carbon.HIToolbox + +extension TextView { + override public func keyDown(with event: NSEvent) { + guard isEditable else { + super.keyDown(with: event) + return + } + + NSCursor.setHiddenUntilMouseMoves(true) + + if !(inputContext?.handleEvent(event) ?? false) { + interpretKeyEvents([event]) + } else { + // Not handled, ignore so we don't double trigger events. + return + } + } + + override public func performKeyEquivalent(with event: NSEvent) -> Bool { + guard isEditable else { + return super.performKeyEquivalent(with: event) + } + + switch Int(event.keyCode) { + case kVK_PageUp: + if !event.modifierFlags.contains(.shift) { + self.pageUp(event) + return true + } + case kVK_PageDown: + if !event.modifierFlags.contains(.shift) { + self.pageDown(event) + return true + } + default: + return false + } + + return false + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Layout.swift b/Sources/CodeEditTextView/TextView/TextView+Layout.swift new file mode 100644 index 00000000..11c8ddc5 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+Layout.swift @@ -0,0 +1,95 @@ +// +// TextView+Layout.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/15/24. +// + +import Foundation + +extension TextView { + open override class var isCompatibleWithResponsiveScrolling: Bool { + true + } + + open override func prepareContent(in rect: NSRect) { + needsLayout = true + super.prepareContent(in: rect) + } + + override public func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + if isSelectable { + selectionManager.drawSelections(in: dirtyRect) + } + } + + override open var isFlipped: Bool { + true + } + + override public var visibleRect: NSRect { + if let scrollView { + var rect = scrollView.documentVisibleRect + rect.origin.y += scrollView.contentInsets.top + return rect.pixelAligned + } else { + return super.visibleRect + } + } + + public var visibleTextRange: NSRange? { + let minY = max(visibleRect.minY, 0) + let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight()) + guard let minYLine = layoutManager.textLineForPosition(minY), + let maxYLine = layoutManager.textLineForPosition(maxY) else { + return nil + } + return NSRange( + location: minYLine.range.location, + length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length + ) + } + + public func updatedViewport(_ newRect: CGRect) { + if !updateFrameIfNeeded() { + layoutManager.layoutLines() + } + inputContext?.invalidateCharacterCoordinates() + } + + @discardableResult + public func updateFrameIfNeeded() -> Bool { + var availableSize = scrollView?.contentSize ?? .zero + availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0) + let newHeight = max(layoutManager.estimatedHeight(), availableSize.height) + let newWidth = layoutManager.estimatedWidth() + + var didUpdate = false + + if newHeight >= availableSize.height && frame.size.height != newHeight { + frame.size.height = newHeight + // No need to update layout after height adjustment + } + + if wrapLines && frame.size.width != availableSize.width { + frame.size.width = availableSize.width + didUpdate = true + } else if !wrapLines && frame.size.width != max(newWidth, availableSize.width) { + frame.size.width = max(newWidth, availableSize.width) + didUpdate = true + } + + if didUpdate { + needsLayout = true + needsDisplay = true + layoutManager.layoutLines() + } + + if isSelectable { + selectionManager?.updateSelectionViews() + } + + return didUpdate + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+Move.swift b/Sources/CodeEditTextView/TextView/TextView+Move.swift index d5b83100..6c2e3d50 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Move.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Move.swift @@ -8,7 +8,7 @@ import Foundation extension TextView { - fileprivate func updateAfterMove() { + fileprivate func updateAfterMove(direction: TextSelectionManager.Direction) { unmarkTextIfNeeded() scrollSelectionToVisible() } @@ -16,166 +16,164 @@ extension TextView { /// Moves the cursors up one character. override public func moveUp(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .character) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors up one character extending the current selection. override public func moveUpAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .character, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors down one character. override public func moveDown(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .character) - updateAfterMove() + updateAfterMove(direction: .down) } /// Moves the cursors down one character extending the current selection. override public func moveDownAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .character, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .down) } /// Moves the cursors left one character. override public func moveLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .character) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors left one character extending the current selection. override public func moveLeftAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .character, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors right one character. override public func moveRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .character) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors right one character extending the current selection. override public func moveRightAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .character, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors left one word. override public func moveWordLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .word) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors left one word extending the current selection. override public func moveWordLeftAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .word, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors right one word. override public func moveWordRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .word) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors right one word extending the current selection. override public func moveWordRightAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .word, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .visualLine) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors left to the end of the line extending the current selection. override public func moveToLeftEndOfLineAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .visualLine, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .backward) } /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .visualLine) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors right to the end of the line extending the current selection. override public func moveToRightEndOfLineAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .visualLine, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .forward) } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up. override public func moveToBeginningOfParagraph(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .line) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors to the beginning of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .line, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors to the end of the line, if pressed again selects the next line up. override public func moveToEndOfParagraph(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .line) - updateAfterMove() + updateAfterMove(direction: .down) } /// Moves the cursors to the end of the line, if pressed again selects the next line up extending the current /// selection. override public func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .line, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .down) } /// Moves the cursors to the beginning of the document. override public func moveToBeginningOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .document) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors to the beginning of the document extending the current selection. override public func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .document, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .up) } /// Moves the cursors to the end of the document. override public func moveToEndOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .document) - updateAfterMove() + updateAfterMove(direction: .down) } /// Moves the cursors to the end of the document extending the current selection. override public func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .document, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .down) } override public func pageUp(_ sender: Any?) { - selectionManager.moveSelections(direction: .up, destination: .page) - updateAfterMove() + enclosingScrollView?.pageUp(sender) } override public func pageUpAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .page, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .up) } override public func pageDown(_ sender: Any?) { - selectionManager.moveSelections(direction: .down, destination: .page) - updateAfterMove() + enclosingScrollView?.pageDown(sender) } override public func pageDownAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .page, modifySelection: true) - updateAfterMove() + updateAfterMove(direction: .down) } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift new file mode 100644 index 00000000..ffcc047c --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -0,0 +1,75 @@ +// +// TextView+ScrollToVisible.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/15/24. +// + +import Foundation + +extension TextView { + /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. + /// - Parameter updateDirection: (optional) the direction of a change in selection. Used to try and keep + /// contextual portions of the selection in the viewport. + public func scrollSelectionToVisible(updateDirection: TextSelectionManager.Direction? = nil) { + guard let scrollView else { + return + } + + // 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 = getSelectionRect(updateDirection), + lastFrame != boundingRect { + lastFrame = boundingRect + layoutManager.layoutLines() + selectionManager.updateSelectionViews() + selectionManager.drawSelections(in: visibleRect) + } + if lastFrame != .zero { + scrollView.contentView.scrollToVisible(lastFrame) + } + } + + /// Get the rect that should be scrolled to visible for the current text selection. + /// - Parameter updateDirection: The direction of the update. + /// - Returns: The rect of the selection. + private func getSelectionRect(_ updateDirection: TextSelectionManager.Direction?) -> CGRect? { + switch updateDirection { + case .forward, .backward, nil: + return selectionManager + .textSelections + .sorted(by: { $0.boundingRect.origin.y < $1.boundingRect.origin.y }) + .first? + .boundingRect + case .up: + guard let selection = selectionManager + .textSelections + .sorted(by: { $0.range.location < $1.range.location }) // Get the highest one. + .first, + let minRect = layoutManager.rectForOffset(selection.range.location) else { + return nil + } + return CGRect( + origin: minRect.origin, + size: CGSize(width: selection.boundingRect.width, height: layoutManager.estimateLineHeight()) + ) + case .down: + guard let selection = selectionManager + .textSelections + .sorted(by: { $0.range.max > $1.range.max }) // Get the lowest one. + .first, + let maxRect = layoutManager.rectForOffset(selection.range.max) else { + return nil + } + let lineHeight = layoutManager.estimateLineHeight() + return CGRect( + origin: CGPoint(x: selection.boundingRect.origin.x, y: maxRect.maxY - lineHeight), + size: CGSize(width: selection.boundingRect.width, height: lineHeight) + ) + } + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView+TextSelectionManagerDelegate.swift b/Sources/CodeEditTextView/TextView/TextView+TextSelectionManagerDelegate.swift new file mode 100644 index 00000000..ff105727 --- /dev/null +++ b/Sources/CodeEditTextView/TextView/TextView+TextSelectionManagerDelegate.swift @@ -0,0 +1,18 @@ +// +// TextView+TextSelectionManagerDelegate.swift +// CodeEditTextView +// +// Created by Khan Winter on 6/15/24. +// + +import Foundation + +extension TextView: TextSelectionManagerDelegate { + public func setNeedsDisplay() { + self.setNeedsDisplay(frame) + } + + public func estimatedLineHeight() -> CGFloat { + layoutManager.estimateLineHeight() + } +} diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index e6a82cf0..d49bbe7f 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -8,10 +8,6 @@ import AppKit import TextStory -// Disabling file length and type body length as the methods and variables contained in this file cannot be moved -// to extensions. -// swiftlint:disable type_body_length - /// # Text View /// /// A view that draws and handles user interactions with text. @@ -336,55 +332,6 @@ public class TextView: NSView, NSTextContent { NSRange(location: 0, length: textStorage.length) } - // MARK: - First Responder - - open override func becomeFirstResponder() -> Bool { - isFirstResponder = true - selectionManager.cursorTimer.resetTimer() - needsDisplay = true - return super.becomeFirstResponder() - } - - open override func resignFirstResponder() -> Bool { - isFirstResponder = false - selectionManager.removeCursors() - needsDisplay = true - return super.resignFirstResponder() - } - - open override var canBecomeKeyView: Bool { - super.canBecomeKeyView && acceptsFirstResponder && !isHiddenOrHasHiddenAncestor - } - - /// Sent to the window's first responder when `NSWindow.makeKey()` occurs. - @objc private func becomeKeyWindow() { - _ = becomeFirstResponder() - } - - /// Sent to the window's first responder when `NSWindow.resignKey()` occurs. - @objc private func resignKeyWindow() { - _ = resignFirstResponder() - } - - open override var needsPanelToBecomeKey: Bool { - isSelectable || isEditable - } - - open override var acceptsFirstResponder: Bool { - isSelectable - } - - open override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - return true - } - - open override func resetCursorRects() { - super.resetCursorRects() - if isSelectable { - addCursorRect(visibleRect, cursor: .iBeam) - } - } - // MARK: - View Lifecycle override public func layout() { @@ -423,137 +370,6 @@ public class TextView: NSView, NSTextContent { } } - // MARK: - Key Down - - override public func keyDown(with event: NSEvent) { - guard isEditable else { - super.keyDown(with: event) - return - } - - NSCursor.setHiddenUntilMouseMoves(true) - - if !(inputContext?.handleEvent(event) ?? false) { - interpretKeyEvents([event]) - } else { - // Handle key events? - } - } - - // MARK: - Layout - - open override class var isCompatibleWithResponsiveScrolling: Bool { - true - } - - open override func prepareContent(in rect: NSRect) { - needsLayout = true - super.prepareContent(in: rect) - } - - override public func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - if isSelectable { - selectionManager.drawSelections(in: dirtyRect) - } - } - - override open var isFlipped: Bool { - true - } - - override public var visibleRect: NSRect { - if let scrollView { - var rect = scrollView.documentVisibleRect - rect.origin.y += scrollView.contentInsets.top - return rect - } else { - return super.visibleRect - } - } - - public var visibleTextRange: NSRange? { - let minY = max(visibleRect.minY, 0) - let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight()) - guard let minYLine = layoutManager.textLineForPosition(minY), - let maxYLine = layoutManager.textLineForPosition(maxY) else { - return nil - } - return NSRange( - location: minYLine.range.location, - length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length - ) - } - - public func updatedViewport(_ newRect: CGRect) { - if !updateFrameIfNeeded() { - layoutManager.layoutLines() - } - inputContext?.invalidateCharacterCoordinates() - } - - @discardableResult - public func updateFrameIfNeeded() -> Bool { - var availableSize = scrollView?.contentSize ?? .zero - availableSize.height -= (scrollView?.contentInsets.top ?? 0) + (scrollView?.contentInsets.bottom ?? 0) - let newHeight = max(layoutManager.estimatedHeight(), availableSize.height) - let newWidth = layoutManager.estimatedWidth() - - var didUpdate = false - - if newHeight >= availableSize.height && frame.size.height != newHeight { - frame.size.height = newHeight - // No need to update layout after height adjustment - } - - if wrapLines && frame.size.width != availableSize.width { - frame.size.width = availableSize.width - didUpdate = true - } else if !wrapLines && frame.size.width != max(newWidth, availableSize.width) { - frame.size.width = max(newWidth, availableSize.width) - didUpdate = true - } - - if didUpdate { - needsLayout = true - needsDisplay = true - layoutManager.layoutLines() - } - - if isSelectable { - selectionManager?.updateSelectionViews() - } - - return didUpdate - } - - /// Scrolls the upmost selection to the visible rect if `scrollView` is not `nil`. - public func scrollSelectionToVisible() { - guard let scrollView else { - return - } - - // 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 selection = selectionManager - .textSelections - .sorted(by: { $0.boundingRect.origin.y < $1.boundingRect.origin.y }) - .first, - lastFrame != selection.boundingRect { - lastFrame = selection.boundingRect - layoutManager.layoutLines() - selectionManager.updateSelectionViews() - selectionManager.drawSelections(in: visibleRect) - } - if lastFrame != .zero { - scrollView.contentView.scrollToVisible(lastFrame) - } - } - deinit { layoutManager = nil selectionManager = nil @@ -561,18 +377,3 @@ public class TextView: NSView, NSTextContent { NotificationCenter.default.removeObserver(self) } } - -// MARK: - TextSelectionManagerDelegate - -extension TextView: TextSelectionManagerDelegate { - public func setNeedsDisplay() { - self.setNeedsDisplay(frame) - } - - public func estimatedLineHeight() -> CGFloat { - layoutManager.estimateLineHeight() - } -} - -// swiftlint:enable type_body_length -// swiftlint:disable:this file_length