Skip to content

Commit

Permalink
Lazier Layout, Cursor Height, First & Last Line Selections (CodeEditA…
Browse files Browse the repository at this point in the history
  • Loading branch information
thecoolwinter authored May 16, 2024
1 parent a436751 commit e421af2
Show file tree
Hide file tree
Showing 9 changed files with 275 additions and 187 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +24,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<TextLine>.TextLineStorageYIterator {
lineStorage.linesStartingAt(minY, until: maxY)
}

struct Iterator: LazySequenceProtocol, IteratorProtocol {
private var storageIterator: TextLineStorage<TextLine>.TextLineStorageYIterator

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,28 @@ extension TextLayoutManager {
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<TextLine>.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<TextLine>.TextLinePosition? {
if offset == lineStorage.length {
return lineStorage.last
Expand All @@ -37,6 +55,12 @@ extension TextLayoutManager {
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 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.
return textStorage?.length
Expand Down
21 changes: 11 additions & 10 deletions Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,16 @@ 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)
|| !visibleLineIds.contains(linePosition.data.id) {
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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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,
Expand Down Expand Up @@ -278,122 +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 {
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)
}
}
Loading

0 comments on commit e421af2

Please sign in to comment.