Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazier Layout, Cursor Height, First & Last Line Selections #30

Merged
merged 2 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading