From 0cf502c82b77326bd87c760366f440f7830a2ea8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 11 May 2024 12:07:58 -0500 Subject: [PATCH 1/2] Lazier Layout, Cursor Height, Docs, Selections --- .../TextLayoutManager+Iterator.swift | 15 ++++++++++ .../TextLayoutManager+Public.swift | 30 +++++++++++++++++-- .../TextLayoutManager/TextLayoutManager.swift | 21 ++++++------- .../TextLineStorage+Iterator.swift | 5 ++-- ...lectionManager+SelectionManipulation.swift | 8 +++++ .../TextSelectionManager.swift | 15 +++++++++- .../CodeEditTextView/TextView/TextView.swift | 8 +++++ 7 files changed, 85 insertions(+), 17 deletions(-) diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index 8c4e8b3d..fd860022 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -8,6 +8,12 @@ import Foundation public extension TextLayoutManager { + /// Iterate over all visible lines. + /// + /// Visible lines are any lines contained by the rect returned by ``TextLayoutManagerDelegate/visibleRect`` or, + /// if there is no delegate from `0` to the estimated document height. + /// + /// - Returns: An iterator to iterate through all visible lines. func visibleLines() -> Iterator { let visibleRect = delegate?.visibleRect ?? NSRect( x: 0, @@ -17,6 +23,15 @@ public extension TextLayoutManager { ) return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage) } + + /// Iterate over all lines in the y position range. + /// - Parameters: + /// - minY: The minimum y position to begin at. + /// - maxY: The maximum y position to iterate to. + /// - Returns: An iterator that will iterate through all text lines in the y position range. + func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorage.TextLineStorageYIterator { + lineStorage.linesStartingAt(minY, until: maxY) + } struct Iterator: LazySequenceProtocol, IteratorProtocol { private var storageIterator: TextLineStorage.TextLineStorageYIterator diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index d1f0281e..dad45701 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -15,11 +15,29 @@ extension TextLayoutManager { public func estimatedWidth() -> CGFloat { maxLineWidth } - + + /// 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 + /// the text view if it exists. Though, for that operation the user should instead use + /// ``TextLayoutManager/textLineForIndex(_:)`` for reliability. + /// + /// - Parameter posY: The y position to find a line for. + /// - Returns: A text line position, if a line could be found at the given y position. public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { lineStorage.getLine(atPosition: posY) } - + + /// Finds a text line for a given text offset. + /// + /// This method will not do any checking for document bounds, and will simply return `nil` if the offset if negative + /// or outside the range of the document. + /// + /// However, if the offset is equal to the length of the text storage (one index past the end of the document) this + /// method will return the last line in the document if it exists. + /// + /// - Parameter offset: The offset in the document to fetch a line for. + /// - Returns: A text line position, if a line could be found at the given offset. public func textLineForOffset(_ offset: Int) -> TextLineStorage.TextLinePosition? { if offset == lineStorage.length { return lineStorage.last @@ -36,7 +54,13 @@ extension TextLayoutManager { guard index >= 0 && index < lineStorage.count else { return nil } return lineStorage.getLine(atIndex: index) } - + + /// Calculates the text position at the given point in the view. + /// - Parameter point: The point to translate to text position. + /// - Returns: The text offset in the document where the given point is laid out. + /// - Warning: If the requested point has not been laid out or it's layout has since been invalidated by edits or + /// other changes, this method will return the invalid data. For best results, ensure the text around theaskdjhlaksj + /// point has been laid out or is visible before calling this method. public func textOffsetAtPoint(_ point: CGPoint) -> Int? { guard point.y <= estimatedHeight() else { // End position is a special case. return textStorage?.length diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 6de10a04..79db9a59 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -206,9 +206,8 @@ public class TextLayoutManager: NSObject { var yContentAdjustment: CGFloat = 0 var maxFoundLineWidth = maxLineWidth - // Layout all lines - for linePosition in lineStorage.linesStartingAt(minY, until: maxY) { - // Updating height in the loop may cause the iterator to be wrong + // Layout all lines, fetching lines lazily as they are laid out. + for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy { guard linePosition.yPos < maxY else { break } if forceLayout || linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth) @@ -216,7 +215,7 @@ public class TextLayoutManager: NSObject { let lineSize = layoutLine( linePosition, textStorage: textStorage, - layoutData: LineLayoutData(minY: linePosition.yPos, maxY: maxY, maxWidth: maxLineLayoutWidth), + layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth), laidOutFragmentIDs: &usedFragmentIDs ) if lineSize.height != linePosition.height { @@ -270,9 +269,7 @@ public class TextLayoutManager: NSObject { /// - Parameters: /// - position: The line position from storage to use for layout. /// - textStorage: The text storage object to use for text info. - /// - minY: The minimum Y value to start at. - /// - maxY: The maximum Y value to end layout at. - /// - maxWidth: The maximum layout width, infinite if ``TextLayoutManager/wrapLines`` is `false`. + /// - layoutData: The information required to perform layout for the given line. /// - laidOutFragmentIDs: Updated by this method as line fragments are laid out. /// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line. private func layoutLine( @@ -302,12 +299,16 @@ 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) - // TODO: Lay out only fragments in min/max Y - for lineFragmentPosition in line.typesetter.lineFragments { + for lineFragmentPosition in line.typesetter.lineFragments.linesStartingAt( + relativeMinY, + until: relativeMaxY + ) { let lineFragment = lineFragmentPosition.data - layoutFragmentView(for: lineFragmentPosition, at: layoutData.minY + lineFragmentPosition.yPos) + layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos) width = max(width, lineFragment.width) height += lineFragment.scaledHeight diff --git a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift index 4588d26e..a7c4b7eb 100644 --- a/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift +++ b/Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift @@ -31,9 +31,8 @@ public extension TextLineStorage { public mutating func next() -> TextLinePosition? { if let currentPosition { - guard currentPosition.yPos < maxY, - let nextPosition = storage.getLine(atOffset: currentPosition.range.max), - nextPosition.index != currentPosition.index else { + guard let nextPosition = storage.getLine(atIndex: currentPosition.index + 1), + nextPosition.yPos < maxY else { return nil } self.currentPosition = nextPosition diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift index 3391b7e1..0ded1877 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift @@ -294,6 +294,14 @@ public extension TextSelectionManager { up: Bool, suggestedXPos: CGFloat? ) -> NSRange { + // If moving up and on first line, jump to beginning of the line + // If moving down and on last line, jump to end of document. + if up && layoutManager?.lineStorage.first?.range.contains(offset) ?? false { + return NSRange(location: 0, length: offset) + } else if !up && layoutManager?.lineStorage.last?.range.contains(offset) ?? false { + return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) + } + switch destination { case .character: return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos) diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index effcae62..5a574927 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -210,7 +210,7 @@ public class TextSelectionManager: NSObject { } cursorView.frame.origin = cursorOrigin - cursorView.frame.size.height = layoutManager?.estimateLineHeight() ?? 0 + cursorView.frame.size.height = heightForCursorAt(textSelection.range) ?? 0 textSelection.view = cursorView textSelection.boundingRect = cursorView.frame @@ -229,6 +229,19 @@ public class TextSelectionManager: NSObject { cursorTimer.resetTimer() } } + + /// Get the height for a cursor placed at the beginning of the given range. + /// - Parameter range: The range the cursor is at. + /// - Returns: The height the cursor should be to match the text at that location. + fileprivate func heightForCursorAt(_ range: NSRange) -> CGFloat? { + let selectedLine = layoutManager?.textLineForOffset(range.location) + return selectedLine? + .data + .lineFragments + .getLine(atOffset: range.location - (selectedLine?.range.location ?? 0))? + .height + + } /// Removes all cursor views and stops the cursor blink timer. func removeCursors() { diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 13b3a5b7..cc32c09a 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -76,16 +76,22 @@ public class TextView: NSView, NSTextContent { } /// The default font of the text view. + /// - Note: Setting the font for the text view will update the font as the user types. To change the font for the + /// entire view, update the `font` attribute in ``TextView/textStorage``. public var font: NSFont { get { (typingAttributes[.font] as? NSFont) ?? NSFont.systemFont(ofSize: 12) } set { typingAttributes[.font] = newValue + layoutManager?.setNeedsLayout() + setNeedsDisplay() } } /// The text color of the text view. + /// - Note: Setting the text color for the text view will update the text color as the user types. To change the + /// text color for the entire view, update the `foregroundColor` attribute in ``TextView/textStorage``. public var textColor: NSColor { get { (typingAttributes[.foregroundColor] as? NSColor) ?? NSColor.textColor @@ -159,6 +165,8 @@ public class TextView: NSView, NSTextContent { } /// The kern to use for characters. Defaults to `0.0` and is updated when `letterSpacing` is set. + /// - Note: Setting the kern for the text view will update the kern as the user types. To change the + /// kern for the entire view, update the `kern` attribute in ``TextView/textStorage``. public var kern: CGFloat { get { typingAttributes[.kern] as? CGFloat ?? 0 From 1a5bc66a6da944bd095cfe668867e3a976ffc50c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Sat, 11 May 2024 12:24:03 -0500 Subject: [PATCH 2/2] Lint + Split Selection Manipulation Methods --- .../TextLayoutManager+Iterator.swift | 2 +- .../TextLayoutManager+Public.swift | 8 +- .../TextLayoutManager/TextLayoutManager.swift | 2 +- .../SelectionManipulation+Horizontal.swift} | 186 +----------------- .../SelectionManipulation+Vertical.swift | 136 +++++++++++++ ...lectionManager+SelectionManipulation.swift | 60 ++++++ .../TextSelectionManager.swift | 2 +- 7 files changed, 208 insertions(+), 188 deletions(-) rename Sources/CodeEditTextView/TextSelectionManager/{TextSelectionManager+SelectionManipulation.swift => SelectionManipulation/SelectionManipulation+Horizontal.swift} (55%) create mode 100644 Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift create mode 100644 Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift index fd860022..ff731527 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift @@ -23,7 +23,7 @@ public extension TextLayoutManager { ) return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage) } - + /// Iterate over all lines in the y position range. /// - Parameters: /// - minY: The minimum y position to begin at. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index dad45701..865d81b7 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -15,7 +15,7 @@ extension TextLayoutManager { public func estimatedWidth() -> CGFloat { maxLineWidth } - + /// 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 @@ -27,7 +27,7 @@ extension TextLayoutManager { public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage.TextLinePosition? { lineStorage.getLine(atPosition: posY) } - + /// Finds a text line for a given text offset. /// /// This method will not do any checking for document bounds, and will simply return `nil` if the offset if negative @@ -54,12 +54,12 @@ extension TextLayoutManager { guard index >= 0 && index < lineStorage.count else { return nil } return lineStorage.getLine(atIndex: index) } - + /// Calculates the text position at the given point in the view. /// - Parameter point: The point to translate to text position. /// - Returns: The text offset in the document where the given point is laid out. /// - Warning: If the requested point has not been laid out or it's layout has since been invalidated by edits or - /// other changes, this method will return the invalid data. For best results, ensure the text around theaskdjhlaksj + /// other changes, this method will return the invalid data. For best results, ensure the text around the /// point has been laid out or is visible before calling this method. public func textOffsetAtPoint(_ point: CGPoint) -> Int? { guard point.y <= estimatedHeight() else { // End position is a special case. diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift index 79db9a59..d6e9583d 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift @@ -303,7 +303,7 @@ public class TextLayoutManager: NSObject { var relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY) for lineFragmentPosition in line.typesetter.lineFragments.linesStartingAt( - relativeMinY, + relativeMinY, until: relativeMaxY ) { let lineFragment = lineFragmentPosition.data diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift similarity index 55% rename from Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift rename to Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift index 0ded1877..c1165d28 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift @@ -1,63 +1,13 @@ // -// TextSelectionManager+SelectionManipulation.swift +// SelectionManipulation+Horizontal.swift // CodeEditTextView // -// Created by Khan Winter on 8/26/23. +// Created by Khan Winter on 5/11/24. // -import AppKit - -public extension TextSelectionManager { - // MARK: - Range Of Selection - - /// Creates a range for a new selection given a starting point, direction, and destination. - /// - Parameters: - /// - offset: The location to start the selection from. - /// - direction: The direction the selection should be created in. - /// - destination: Determines how far the selection is. - /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: A range of a new selection based on the direction and destination. - func rangeOfSelection( - from offset: Int, - direction: Direction, - destination: Destination, - decomposeCharacters: Bool = false, - suggestedXPos: CGFloat? = nil - ) -> NSRange { - switch direction { - case .backward: - guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 - return extendSelection( - from: offset, - destination: destination, - delta: -1, - decomposeCharacters: decomposeCharacters - ) - case .forward: - return extendSelection( - from: offset, - destination: destination, - delta: 1, - decomposeCharacters: decomposeCharacters - ) - case .up: - return extendSelectionVertical( - from: offset, - destination: destination, - up: true, - suggestedXPos: suggestedXPos - ) - case .down: - return extendSelectionVertical( - from: offset, - destination: destination, - up: false, - suggestedXPos: suggestedXPos - ) - } - } +import Foundation +package extension TextSelectionManager { /// Extends a selection from the given offset determining the length by the destination. /// /// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion` @@ -68,7 +18,7 @@ public extension TextSelectionManager { /// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards. /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. /// - Returns: A new range to merge with a selection. - private func extendSelection( + func extendSelectionHorizontal( from offset: Int, destination: Destination, delta: Int, @@ -278,130 +228,4 @@ public extension TextSelectionManager { } return foundRange } - - // MARK: - Vertical Methods - - /// Extends a selection from the given offset vertically to the destination. - /// - Parameters: - /// - offset: The offset to extend from. - /// - destination: The destination to extend to. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVertical( - from offset: Int, - destination: Destination, - up: Bool, - suggestedXPos: CGFloat? - ) -> NSRange { - // If moving up and on first line, jump to beginning of the line - // If moving down and on last line, jump to end of document. - if up && layoutManager?.lineStorage.first?.range.contains(offset) ?? false { - return NSRange(location: 0, length: offset) - } else if !up && layoutManager?.lineStorage.last?.range.contains(offset) ?? false { - return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) - } - - switch destination { - case .character: - 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 .document: - if up { - return NSRange(location: 0, length: offset) - } else { - return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) - } - } - } - - /// Extends the selection to the nearest character vertically. - /// - Parameters: - /// - offset: The offset to extend from. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVerticalCharacter( - from offset: Int, - up: Bool, - suggestedXPos: CGFloat? - ) -> NSRange { - guard let point = layoutManager?.rectForOffset(offset)?.origin, - let newOffset = layoutManager?.textOffsetAtPoint( - CGPoint( - x: suggestedXPos == nil ? point.x : suggestedXPos!, - y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) - ) - ) else { - return NSRange(location: offset, length: 0) - } - - return NSRange( - location: up ? newOffset : offset, - length: up ? offset - newOffset : newOffset - offset - ) - } - - /// Extends the selection to the nearest line vertically. - /// - /// If moving up and the offset is in the middle of the line, it first extends it to the beginning of the line. - /// On the second call, it will extend it to the beginning of the previous line. When moving down, the - /// same thing will happen in the opposite direction. - /// - /// - Parameters: - /// - offset: The offset to extend from. - /// - up: Set to true if extending up. - /// - suggestedXPos: The suggested x position to stick to. - /// - Returns: The range of the extended selection. - private func extendSelectionVerticalLine( - from offset: Int, - up: Bool - ) -> NSRange { - // Important distinction here, when moving up/down on a line and in the middle of the line, we move to the - // beginning/end of the *entire* line, not the line fragment. - guard let line = layoutManager?.textLineForOffset(offset) else { - return NSRange(location: offset, length: 0) - } - if up && line.range.location != offset { - return NSRange(location: line.range.location, length: offset - line.index) - } else if !up && line.range.max - (layoutManager?.detectedLineEnding.length ?? 0) != offset { - return NSRange( - location: offset, - length: line.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) - ) - } else { - let nextQueryIndex = up ? max(line.range.location - 1, 0) : min(line.range.max, (textStorage?.length ?? 0)) - guard let nextLine = layoutManager?.textLineForOffset(nextQueryIndex) else { - return NSRange(location: offset, length: 0) - } - return NSRange( - location: up ? nextLine.range.location : offset, - length: up - ? offset - nextLine.range.location - : nextLine.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) - ) - } - } - - /// Extends a selection one "container" 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 { - 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 { - return NSRange(location: offset, length: 0) - } - return endOffset > offset - ? NSRange(location: offset, length: endOffset - offset) - : NSRange(location: endOffset, length: offset - endOffset) - } } diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift new file mode 100644 index 00000000..ea8417bf --- /dev/null +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Vertical.swift @@ -0,0 +1,136 @@ +// +// SelectionManipulation+Vertical.swift +// CodeEditTextView +// +// Created by Khan Winter on 5/11/24. +// + +import Foundation + +package extension TextSelectionManager { + // MARK: - Vertical Methods + + /// Extends a selection from the given offset vertically to the destination. + /// - Parameters: + /// - offset: The offset to extend from. + /// - destination: The destination to extend to. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + func extendSelectionVertical( + from offset: Int, + destination: Destination, + up: Bool, + suggestedXPos: CGFloat? + ) -> NSRange { + // If moving up and on first line, jump to beginning of the line + // If moving down and on last line, jump to end of document. + if up && layoutManager?.lineStorage.first?.range.contains(offset) ?? false { + return NSRange(location: 0, length: offset) + } else if !up && layoutManager?.lineStorage.last?.range.contains(offset) ?? false { + return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) + } + + switch destination { + case .character: + 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 .document: + if up { + return NSRange(location: 0, length: offset) + } else { + return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset) + } + } + } + + /// Extends the selection to the nearest character vertically. + /// - Parameters: + /// - offset: The offset to extend from. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + private func extendSelectionVerticalCharacter( + from offset: Int, + up: Bool, + suggestedXPos: CGFloat? + ) -> NSRange { + guard let point = layoutManager?.rectForOffset(offset)?.origin, + let newOffset = layoutManager?.textOffsetAtPoint( + CGPoint( + x: suggestedXPos == nil ? point.x : suggestedXPos!, + y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3) + ) + ) else { + return NSRange(location: offset, length: 0) + } + + return NSRange( + location: up ? newOffset : offset, + length: up ? offset - newOffset : newOffset - offset + ) + } + + /// Extends the selection to the nearest line vertically. + /// + /// If moving up and the offset is in the middle of the line, it first extends it to the beginning of the line. + /// On the second call, it will extend it to the beginning of the previous line. When moving down, the + /// same thing will happen in the opposite direction. + /// + /// - Parameters: + /// - offset: The offset to extend from. + /// - up: Set to true if extending up. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: The range of the extended selection. + private func extendSelectionVerticalLine( + from offset: Int, + up: Bool + ) -> NSRange { + // Important distinction here, when moving up/down on a line and in the middle of the line, we move to the + // beginning/end of the *entire* line, not the line fragment. + guard let line = layoutManager?.textLineForOffset(offset) else { + return NSRange(location: offset, length: 0) + } + if up && line.range.location != offset { + return NSRange(location: line.range.location, length: offset - line.index) + } else if !up && line.range.max - (layoutManager?.detectedLineEnding.length ?? 0) != offset { + return NSRange( + location: offset, + length: line.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) + ) + } else { + let nextQueryIndex = up ? max(line.range.location - 1, 0) : min(line.range.max, (textStorage?.length ?? 0)) + guard let nextLine = layoutManager?.textLineForOffset(nextQueryIndex) else { + return NSRange(location: offset, length: 0) + } + return NSRange( + location: up ? nextLine.range.location : offset, + length: up + ? offset - nextLine.range.location + : nextLine.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0) + ) + } + } + + /// Extends a selection one "container" 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 { + 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 { + return NSRange(location: offset, length: 0) + } + return endOffset > offset + ? NSRange(location: offset, length: endOffset - offset) + : NSRange(location: endOffset, length: offset - endOffset) + } +} diff --git a/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift new file mode 100644 index 00000000..d94563a7 --- /dev/null +++ b/Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/TextSelectionManager+SelectionManipulation.swift @@ -0,0 +1,60 @@ +// +// TextSelectionManager+SelectionManipulation.swift +// CodeEditTextView +// +// Created by Khan Winter on 8/26/23. +// + +import AppKit + +public extension TextSelectionManager { + // MARK: - Range Of Selection + + /// Creates a range for a new selection given a starting point, direction, and destination. + /// - Parameters: + /// - offset: The location to start the selection from. + /// - direction: The direction the selection should be created in. + /// - destination: Determines how far the selection is. + /// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters. + /// - suggestedXPos: The suggested x position to stick to. + /// - Returns: A range of a new selection based on the direction and destination. + func rangeOfSelection( + from offset: Int, + direction: Direction, + destination: Destination, + decomposeCharacters: Bool = false, + suggestedXPos: CGFloat? = nil + ) -> NSRange { + switch direction { + case .backward: + guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0 + return extendSelectionHorizontal( + from: offset, + destination: destination, + delta: -1, + decomposeCharacters: decomposeCharacters + ) + case .forward: + return extendSelectionHorizontal( + from: offset, + destination: destination, + delta: 1, + decomposeCharacters: decomposeCharacters + ) + case .up: + return extendSelectionVertical( + from: offset, + destination: destination, + up: true, + suggestedXPos: suggestedXPos + ) + case .down: + return extendSelectionVertical( + from: offset, + destination: destination, + up: false, + suggestedXPos: suggestedXPos + ) + } + } +} diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 5a574927..bc7b3955 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -229,7 +229,7 @@ public class TextSelectionManager: NSObject { cursorTimer.resetTimer() } } - + /// Get the height for a cursor placed at the beginning of the given range. /// - Parameter range: The range the cursor is at. /// - Returns: The height the cursor should be to match the text at that location.