From 3fe867045b6dd356106340d9afc7a96b1812f912 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 13 Jun 2024 14:54:07 -0500 Subject: [PATCH 1/4] Start Page Up/Down Methods --- .../TextLayoutManager/TextLayoutManager.swift | 4 ++-- .../SelectionManipulation+Horizontal.swift | 4 +++- .../SelectionManipulation+Vertical.swift | 8 ++++---- .../TextSelectionManager.swift | 3 +-- .../TextView/TextView+Move.swift | 20 +++++++++++++++++++ 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index d6e9583dd..d713620af 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -299,8 +299,8 @@ public class TextLayoutManager: NSObject { var height: CGFloat = 0 var width: CGFloat = 0 - var relativeMinY = max(layoutData.minY - position.yPos, 0) - var relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) + let relativeMinY = max(layoutData.minY - position.yPos, 0) + let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) for lineFragmentPosition in line.typesetter.lineFragments.linesStartingAt( relativeMinY, diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift index c1165d281..73269247c 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift @@ -36,7 +36,7 @@ package extension TextSelectionManager { ) case .word: return extendSelectionWord(string: string, from: offset, delta: delta) - case .line, .container: + case .line: return extendSelectionLine(string: string, from: offset, delta: delta) case .visualLine: return extendSelectionVisualLine(string: string, from: offset, delta: delta) @@ -46,6 +46,8 @@ package extension TextSelectionManager { } else { return NSRange(location: 0, length: offset) } + case .page: // Not a valid destination horizontally. + return NSRange(location: offset, length: 0) } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift index ea8417bf7..5c4c54e96 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift @@ -36,8 +36,8 @@ package extension TextSelectionManager { return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos) case .word, .line, .visualLine: return extendSelectionVerticalLine(from: offset, up: up) - case .container: - return extendSelectionContainer(from: offset, delta: up ? 1 : -1) + case .page: + return extendSelectionPage(from: offset, delta: up ? 1 : -1) case .document: if up { return NSRange(location: 0, length: offset) @@ -115,12 +115,12 @@ package extension TextSelectionManager { } } - /// Extends a selection one "container" long. + /// Extends a selection one "page" long. /// - Parameters: /// - 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 extendSelectionContainer(from offset: Int, delta: Int) -> NSRange { + 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, diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index bc7b39555..889ab818f 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -52,8 +52,7 @@ public class TextSelectionManager: NSObject { case word case line case visualLine - /// Eg: Bottom of screen - case container + case page case document } diff --git a/Sources/CodeEditTextView/TextView/TextView+Move.swift b/Sources/CodeEditTextView/TextView/TextView+Move.swift index 849b4f478..d5b83100c 100644 --- a/Sources/CodeEditTextView/TextView/TextView+Move.swift +++ b/Sources/CodeEditTextView/TextView/TextView+Move.swift @@ -158,4 +158,24 @@ extension TextView { selectionManager.moveSelections(direction: .down, destination: .document, modifySelection: true) updateAfterMove() } + + override public func pageUp(_ sender: Any?) { + selectionManager.moveSelections(direction: .up, destination: .page) + updateAfterMove() + } + + override public func pageUpAndModifySelection(_ sender: Any?) { + selectionManager.moveSelections(direction: .up, destination: .page, modifySelection: true) + updateAfterMove() + } + + override public func pageDown(_ sender: Any?) { + selectionManager.moveSelections(direction: .down, destination: .page) + updateAfterMove() + } + + override public func pageDownAndModifySelection(_ sender: Any?) { + selectionManager.moveSelections(direction: .down, destination: .page, modifySelection: true) + updateAfterMove() + } } From b51f8e29417d05f02a649bdf163b6befd5f11883 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:43:08 -0500 Subject: [PATCH 2/4] Page Up/Down Works --- .../TextLayoutManager/TextLayoutManager.swift | 4 +- .../SelectionManipulation+Vertical.swift | 36 ++-- .../TextSelectionManager.swift | 10 +- .../TextView/TextView+FirstResponder.swift | 57 +++++ .../TextView/TextView+KeyDown.swift | 50 +++++ .../TextView/TextView+Layout.swift | 95 +++++++++ .../TextView/TextView+Move.swift | 60 +++--- .../TextView/TextView+ScrollToVisible.swift | 75 +++++++ ...extView+TextSelectionManagerDelegate.swift | 18 ++ .../CodeEditTextView/TextView/TextView.swift | 199 ------------------ 10 files changed, 356 insertions(+), 248 deletions(-) create mode 100644 Sources/CodeEditTextView/TextView/TextView+FirstResponder.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+KeyDown.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+Layout.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift create mode 100644 Sources/CodeEditTextView/TextView/TextView+TextSelectionManagerDelegate.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index d713620af..8bd09bd61 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 5c4c54e96..fd2ade307 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 889ab818f..bcd2ddead 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 000000000..39588a262 --- /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 000000000..1ef36d4f5 --- /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 000000000..11c8ddc50 --- /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 d5b83100c..6c2e3d507 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 000000000..ffcc047ce --- /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 000000000..ff1057271 --- /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 e6a82cf08..d49bbe7fa 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 From daa888dd8506ce0837780b9394bf9089d87855f0 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:24:00 -0500 Subject: [PATCH 3/4] Finalize scrollSelectionToVisible Logic --- .../TextView/TextView+Move.swift | 54 +++++++------- .../TextView/TextView+ScrollToVisible.swift | 70 ++++++++----------- 2 files changed, 55 insertions(+), 69 deletions(-) diff --git a/Sources/CodeEditTextView/TextView/TextView+Move.swift b/Sources/CodeEditTextView/TextView/TextView+Move.swift index 6c2e3d507..d130f16e1 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(direction: TextSelectionManager.Direction) { + fileprivate func updateAfterMove() { unmarkTextIfNeeded() scrollSelectionToVisible() } @@ -16,147 +16,147 @@ extension TextView { /// Moves the cursors up one character. override public func moveUp(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .character) - updateAfterMove(direction: .up) + updateAfterMove() } /// 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(direction: .up) + updateAfterMove() } /// Moves the cursors down one character. override public func moveDown(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .character) - updateAfterMove(direction: .down) + updateAfterMove() } /// 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(direction: .down) + updateAfterMove() } /// Moves the cursors left one character. override public func moveLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .character) - updateAfterMove(direction: .backward) + updateAfterMove() } /// 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(direction: .backward) + updateAfterMove() } /// Moves the cursors right one character. override public func moveRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .character) - updateAfterMove(direction: .forward) + updateAfterMove() } /// 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(direction: .forward) + updateAfterMove() } /// Moves the cursors left one word. override public func moveWordLeft(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .word) - updateAfterMove(direction: .backward) + updateAfterMove() } /// 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(direction: .backward) + updateAfterMove() } /// Moves the cursors right one word. override public func moveWordRight(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .word) - updateAfterMove(direction: .forward) + updateAfterMove() } /// 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(direction: .forward) + updateAfterMove() } /// Moves the cursors left to the end of the line. override public func moveToLeftEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .backward, destination: .visualLine) - updateAfterMove(direction: .backward) + updateAfterMove() } /// 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(direction: .backward) + updateAfterMove() } /// Moves the cursors right to the end of the line. override public func moveToRightEndOfLine(_ sender: Any?) { selectionManager.moveSelections(direction: .forward, destination: .visualLine) - updateAfterMove(direction: .forward) + updateAfterMove() } /// 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(direction: .forward) + updateAfterMove() } /// 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(direction: .up) + updateAfterMove() } /// 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(direction: .up) + updateAfterMove() } /// 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(direction: .down) + updateAfterMove() } /// 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(direction: .down) + updateAfterMove() } /// Moves the cursors to the beginning of the document. override public func moveToBeginningOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .document) - updateAfterMove(direction: .up) + updateAfterMove() } /// 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(direction: .up) + updateAfterMove() } /// Moves the cursors to the end of the document. override public func moveToEndOfDocument(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .document) - updateAfterMove(direction: .down) + updateAfterMove() } /// 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(direction: .down) + updateAfterMove() } override public func pageUp(_ sender: Any?) { @@ -165,7 +165,7 @@ extension TextView { override public func pageUpAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .up, destination: .page, modifySelection: true) - updateAfterMove(direction: .up) + updateAfterMove() } override public func pageDown(_ sender: Any?) { @@ -174,6 +174,6 @@ extension TextView { override public func pageDownAndModifySelection(_ sender: Any?) { selectionManager.moveSelections(direction: .down, destination: .page, modifySelection: true) - updateAfterMove(direction: .down) + updateAfterMove() } } diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index ffcc047ce..111b12857 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -8,22 +8,24 @@ import Foundation extension TextView { + fileprivate typealias Direction = TextSelectionManager.Direction + fileprivate typealias TextSelection = TextSelectionManager.TextSelection + /// 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 { + public func scrollSelectionToVisible() { + guard let scrollView, let selection = getSelection() else { return } + let offsetToScrollTo = offsetNotPivot(selection) + // There's a bit of a chicken-and-the-egg issue going on here. We need to know the rect to scroll to, but we // can't know the exact rect to make visible without laying out the text. Then, once text is laid out the // selection rect may be different again. To solve this, we loop until the frame doesn't change after a layout // pass and scroll to that rect. var lastFrame: CGRect = .zero - while let boundingRect = getSelectionRect(updateDirection), - lastFrame != boundingRect { + while let boundingRect = layoutManager.rectForOffset(offsetToScrollTo), lastFrame != boundingRect { lastFrame = boundingRect layoutManager.layoutLines() selectionManager.updateSelectionViews() @@ -34,42 +36,26 @@ extension TextView { } } - /// 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) - ) + /// Get the selection that should be scrolled to visible for the current text selection. + /// - Returns: The the selection to scroll to. + private func getSelection() -> TextSelection? { + selectionManager + .textSelections + .sorted(by: { $0.range.max > $1.range.max }) // Get the lowest one. + .first + } + + /// Returns the offset that isn't the pivot of the selection. + /// - Parameter selection: The selection to use. + /// - Returns: The offset suitable for scrolling to. + private func offsetNotPivot(_ selection: TextSelection) -> Int { + guard let pivot = selection.pivot else { + return selection.range.location + } + if selection.range.location == pivot { + return selection.range.max + } else { + return selection.range.location } } } From 35165659efcc1a62836232eb196b2cfbb4694262 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:39:11 -0500 Subject: [PATCH 4/4] Lint! --- .../SelectionManipulation+Vertical.swift | 6 ++++-- .../TextView/TextView+ScrollToVisible.swift | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift index fd2ade307..852946201 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift @@ -121,7 +121,7 @@ package extension TextSelectionManager { /// - 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, suggestedXPos: CGFloat?) -> NSRange { - guard let textView = textView, + guard let textView = textView, let layoutManager, let currentYPos = layoutManager.rectForOffset(offset)?.origin.y else { return NSRange(location: offset, length: 0) @@ -131,7 +131,9 @@ package extension TextSelectionManager { // 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)) + 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))) diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index 111b12857..bf1d9ba42 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -44,7 +44,7 @@ extension TextView { .sorted(by: { $0.range.max > $1.range.max }) // Get the lowest one. .first } - + /// Returns the offset that isn't the pivot of the selection. /// - Parameter selection: The selection to use. /// - Returns: The offset suitable for scrolling to.