Skip to content

Commit e421af2

Browse files
Lazier Layout, Cursor Height, First & Last Line Selections (#30)
1 parent a436751 commit e421af2

File tree

9 files changed

+275
-187
lines changed

9 files changed

+275
-187
lines changed

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Iterator.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
import Foundation
99

1010
public extension TextLayoutManager {
11+
/// Iterate over all visible lines.
12+
///
13+
/// Visible lines are any lines contained by the rect returned by ``TextLayoutManagerDelegate/visibleRect`` or,
14+
/// if there is no delegate from `0` to the estimated document height.
15+
///
16+
/// - Returns: An iterator to iterate through all visible lines.
1117
func visibleLines() -> Iterator {
1218
let visibleRect = delegate?.visibleRect ?? NSRect(
1319
x: 0,
@@ -18,6 +24,15 @@ public extension TextLayoutManager {
1824
return Iterator(minY: max(visibleRect.minY, 0), maxY: max(visibleRect.maxY, 0), storage: self.lineStorage)
1925
}
2026

27+
/// Iterate over all lines in the y position range.
28+
/// - Parameters:
29+
/// - minY: The minimum y position to begin at.
30+
/// - maxY: The maximum y position to iterate to.
31+
/// - Returns: An iterator that will iterate through all text lines in the y position range.
32+
func linesStartingAt(_ minY: CGFloat, until maxY: CGFloat) -> TextLineStorage<TextLine>.TextLineStorageYIterator {
33+
lineStorage.linesStartingAt(minY, until: maxY)
34+
}
35+
2136
struct Iterator: LazySequenceProtocol, IteratorProtocol {
2237
private var storageIterator: TextLineStorage<TextLine>.TextLineStorageYIterator
2338

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,28 @@ extension TextLayoutManager {
1616
maxLineWidth
1717
}
1818

19+
/// Finds a text line for the given y position relative to the text view.
20+
///
21+
/// Y values begin at the top of the view and extend down. Eg, a `0` y value would return the first line in
22+
/// the text view if it exists. Though, for that operation the user should instead use
23+
/// ``TextLayoutManager/textLineForIndex(_:)`` for reliability.
24+
///
25+
/// - Parameter posY: The y position to find a line for.
26+
/// - Returns: A text line position, if a line could be found at the given y position.
1927
public func textLineForPosition(_ posY: CGFloat) -> TextLineStorage<TextLine>.TextLinePosition? {
2028
lineStorage.getLine(atPosition: posY)
2129
}
2230

31+
/// Finds a text line for a given text offset.
32+
///
33+
/// This method will not do any checking for document bounds, and will simply return `nil` if the offset if negative
34+
/// or outside the range of the document.
35+
///
36+
/// However, if the offset is equal to the length of the text storage (one index past the end of the document) this
37+
/// method will return the last line in the document if it exists.
38+
///
39+
/// - Parameter offset: The offset in the document to fetch a line for.
40+
/// - Returns: A text line position, if a line could be found at the given offset.
2341
public func textLineForOffset(_ offset: Int) -> TextLineStorage<TextLine>.TextLinePosition? {
2442
if offset == lineStorage.length {
2543
return lineStorage.last
@@ -37,6 +55,12 @@ extension TextLayoutManager {
3755
return lineStorage.getLine(atIndex: index)
3856
}
3957

58+
/// Calculates the text position at the given point in the view.
59+
/// - Parameter point: The point to translate to text position.
60+
/// - Returns: The text offset in the document where the given point is laid out.
61+
/// - Warning: If the requested point has not been laid out or it's layout has since been invalidated by edits or
62+
/// other changes, this method will return the invalid data. For best results, ensure the text around the
63+
/// point has been laid out or is visible before calling this method.
4064
public func textOffsetAtPoint(_ point: CGPoint) -> Int? {
4165
guard point.y <= estimatedHeight() else { // End position is a special case.
4266
return textStorage?.length

Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager.swift

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,17 +206,16 @@ public class TextLayoutManager: NSObject {
206206
var yContentAdjustment: CGFloat = 0
207207
var maxFoundLineWidth = maxLineWidth
208208

209-
// Layout all lines
210-
for linePosition in lineStorage.linesStartingAt(minY, until: maxY) {
211-
// Updating height in the loop may cause the iterator to be wrong
209+
// Layout all lines, fetching lines lazily as they are laid out.
210+
for linePosition in lineStorage.linesStartingAt(minY, until: maxY).lazy {
212211
guard linePosition.yPos < maxY else { break }
213212
if forceLayout
214213
|| linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth)
215214
|| !visibleLineIds.contains(linePosition.data.id) {
216215
let lineSize = layoutLine(
217216
linePosition,
218217
textStorage: textStorage,
219-
layoutData: LineLayoutData(minY: linePosition.yPos, maxY: maxY, maxWidth: maxLineLayoutWidth),
218+
layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth),
220219
laidOutFragmentIDs: &usedFragmentIDs
221220
)
222221
if lineSize.height != linePosition.height {
@@ -270,9 +269,7 @@ public class TextLayoutManager: NSObject {
270269
/// - Parameters:
271270
/// - position: The line position from storage to use for layout.
272271
/// - textStorage: The text storage object to use for text info.
273-
/// - minY: The minimum Y value to start at.
274-
/// - maxY: The maximum Y value to end layout at.
275-
/// - maxWidth: The maximum layout width, infinite if ``TextLayoutManager/wrapLines`` is `false`.
272+
/// - layoutData: The information required to perform layout for the given line.
276273
/// - laidOutFragmentIDs: Updated by this method as line fragments are laid out.
277274
/// - Returns: A `CGSize` representing the max width and total height of the laid out portion of the line.
278275
private func layoutLine(
@@ -302,12 +299,16 @@ public class TextLayoutManager: NSObject {
302299

303300
var height: CGFloat = 0
304301
var width: CGFloat = 0
302+
var relativeMinY = max(layoutData.minY - position.yPos, 0)
303+
var relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY)
305304

306-
// TODO: Lay out only fragments in min/max Y
307-
for lineFragmentPosition in line.typesetter.lineFragments {
305+
for lineFragmentPosition in line.typesetter.lineFragments.linesStartingAt(
306+
relativeMinY,
307+
until: relativeMaxY
308+
) {
308309
let lineFragment = lineFragmentPosition.data
309310

310-
layoutFragmentView(for: lineFragmentPosition, at: layoutData.minY + lineFragmentPosition.yPos)
311+
layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos)
311312

312313
width = max(width, lineFragment.width)
313314
height += lineFragment.scaledHeight

Sources/CodeEditTextView/TextLineStorage/TextLineStorage+Iterator.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ public extension TextLineStorage {
3131

3232
public mutating func next() -> TextLinePosition? {
3333
if let currentPosition {
34-
guard currentPosition.yPos < maxY,
35-
let nextPosition = storage.getLine(atOffset: currentPosition.range.max),
36-
nextPosition.index != currentPosition.index else {
34+
guard let nextPosition = storage.getLine(atIndex: currentPosition.index + 1),
35+
nextPosition.yPos < maxY else {
3736
return nil
3837
}
3938
self.currentPosition = nextPosition

Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+SelectionManipulation.swift renamed to Sources/CodeEditTextView/TextSelectionManager/SelectionManipulation/SelectionManipulation+Horizontal.swift

Lines changed: 5 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,13 @@
11
//
2-
// TextSelectionManager+SelectionManipulation.swift
2+
// SelectionManipulation+Horizontal.swift
33
// CodeEditTextView
44
//
5-
// Created by Khan Winter on 8/26/23.
5+
// Created by Khan Winter on 5/11/24.
66
//
77

8-
import AppKit
9-
10-
public extension TextSelectionManager {
11-
// MARK: - Range Of Selection
12-
13-
/// Creates a range for a new selection given a starting point, direction, and destination.
14-
/// - Parameters:
15-
/// - offset: The location to start the selection from.
16-
/// - direction: The direction the selection should be created in.
17-
/// - destination: Determines how far the selection is.
18-
/// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters.
19-
/// - suggestedXPos: The suggested x position to stick to.
20-
/// - Returns: A range of a new selection based on the direction and destination.
21-
func rangeOfSelection(
22-
from offset: Int,
23-
direction: Direction,
24-
destination: Destination,
25-
decomposeCharacters: Bool = false,
26-
suggestedXPos: CGFloat? = nil
27-
) -> NSRange {
28-
switch direction {
29-
case .backward:
30-
guard offset > 0 else { return NSRange(location: offset, length: 0) } // Can't go backwards beyond 0
31-
return extendSelection(
32-
from: offset,
33-
destination: destination,
34-
delta: -1,
35-
decomposeCharacters: decomposeCharacters
36-
)
37-
case .forward:
38-
return extendSelection(
39-
from: offset,
40-
destination: destination,
41-
delta: 1,
42-
decomposeCharacters: decomposeCharacters
43-
)
44-
case .up:
45-
return extendSelectionVertical(
46-
from: offset,
47-
destination: destination,
48-
up: true,
49-
suggestedXPos: suggestedXPos
50-
)
51-
case .down:
52-
return extendSelectionVertical(
53-
from: offset,
54-
destination: destination,
55-
up: false,
56-
suggestedXPos: suggestedXPos
57-
)
58-
}
59-
}
8+
import Foundation
609

10+
package extension TextSelectionManager {
6111
/// Extends a selection from the given offset determining the length by the destination.
6212
///
6313
/// Returns a new range that needs to be merged with an existing selection range using `NSRange.formUnion`
@@ -68,7 +18,7 @@ public extension TextSelectionManager {
6818
/// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
6919
/// - decomposeCharacters: Set to `true` to treat grapheme clusters as individual characters.
7020
/// - Returns: A new range to merge with a selection.
71-
private func extendSelection(
21+
func extendSelectionHorizontal(
7222
from offset: Int,
7323
destination: Destination,
7424
delta: Int,
@@ -278,122 +228,4 @@ public extension TextSelectionManager {
278228
}
279229
return foundRange
280230
}
281-
282-
// MARK: - Vertical Methods
283-
284-
/// Extends a selection from the given offset vertically to the destination.
285-
/// - Parameters:
286-
/// - offset: The offset to extend from.
287-
/// - destination: The destination to extend to.
288-
/// - up: Set to true if extending up.
289-
/// - suggestedXPos: The suggested x position to stick to.
290-
/// - Returns: The range of the extended selection.
291-
private func extendSelectionVertical(
292-
from offset: Int,
293-
destination: Destination,
294-
up: Bool,
295-
suggestedXPos: CGFloat?
296-
) -> NSRange {
297-
switch destination {
298-
case .character:
299-
return extendSelectionVerticalCharacter(from: offset, up: up, suggestedXPos: suggestedXPos)
300-
case .word, .line, .visualLine:
301-
return extendSelectionVerticalLine(from: offset, up: up)
302-
case .container:
303-
return extendSelectionContainer(from: offset, delta: up ? 1 : -1)
304-
case .document:
305-
if up {
306-
return NSRange(location: 0, length: offset)
307-
} else {
308-
return NSRange(location: offset, length: (textStorage?.length ?? 0) - offset)
309-
}
310-
}
311-
}
312-
313-
/// Extends the selection to the nearest character vertically.
314-
/// - Parameters:
315-
/// - offset: The offset to extend from.
316-
/// - up: Set to true if extending up.
317-
/// - suggestedXPos: The suggested x position to stick to.
318-
/// - Returns: The range of the extended selection.
319-
private func extendSelectionVerticalCharacter(
320-
from offset: Int,
321-
up: Bool,
322-
suggestedXPos: CGFloat?
323-
) -> NSRange {
324-
guard let point = layoutManager?.rectForOffset(offset)?.origin,
325-
let newOffset = layoutManager?.textOffsetAtPoint(
326-
CGPoint(
327-
x: suggestedXPos == nil ? point.x : suggestedXPos!,
328-
y: point.y - (layoutManager?.estimateLineHeight() ?? 2.0)/2 * (up ? 1 : -3)
329-
)
330-
) else {
331-
return NSRange(location: offset, length: 0)
332-
}
333-
334-
return NSRange(
335-
location: up ? newOffset : offset,
336-
length: up ? offset - newOffset : newOffset - offset
337-
)
338-
}
339-
340-
/// Extends the selection to the nearest line vertically.
341-
///
342-
/// If moving up and the offset is in the middle of the line, it first extends it to the beginning of the line.
343-
/// On the second call, it will extend it to the beginning of the previous line. When moving down, the
344-
/// same thing will happen in the opposite direction.
345-
///
346-
/// - Parameters:
347-
/// - offset: The offset to extend from.
348-
/// - up: Set to true if extending up.
349-
/// - suggestedXPos: The suggested x position to stick to.
350-
/// - Returns: The range of the extended selection.
351-
private func extendSelectionVerticalLine(
352-
from offset: Int,
353-
up: Bool
354-
) -> NSRange {
355-
// Important distinction here, when moving up/down on a line and in the middle of the line, we move to the
356-
// beginning/end of the *entire* line, not the line fragment.
357-
guard let line = layoutManager?.textLineForOffset(offset) else {
358-
return NSRange(location: offset, length: 0)
359-
}
360-
if up && line.range.location != offset {
361-
return NSRange(location: line.range.location, length: offset - line.index)
362-
} else if !up && line.range.max - (layoutManager?.detectedLineEnding.length ?? 0) != offset {
363-
return NSRange(
364-
location: offset,
365-
length: line.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0)
366-
)
367-
} else {
368-
let nextQueryIndex = up ? max(line.range.location - 1, 0) : min(line.range.max, (textStorage?.length ?? 0))
369-
guard let nextLine = layoutManager?.textLineForOffset(nextQueryIndex) else {
370-
return NSRange(location: offset, length: 0)
371-
}
372-
return NSRange(
373-
location: up ? nextLine.range.location : offset,
374-
length: up
375-
? offset - nextLine.range.location
376-
: nextLine.range.max - offset - (layoutManager?.detectedLineEnding.length ?? 0)
377-
)
378-
}
379-
}
380-
381-
/// Extends a selection one "container" long.
382-
/// - Parameters:
383-
/// - offset: The location to start extending the selection from.
384-
/// - delta: The direction the selection should be extended. `1` for forwards, `-1` for backwards.
385-
/// - Returns: The range of the extended selection.
386-
private func extendSelectionContainer(from offset: Int, delta: Int) -> NSRange {
387-
guard let textView, let endOffset = layoutManager?.textOffsetAtPoint(
388-
CGPoint(
389-
x: delta > 0 ? textView.frame.maxX : textView.frame.minX,
390-
y: delta > 0 ? textView.frame.maxY : textView.frame.minY
391-
)
392-
) else {
393-
return NSRange(location: offset, length: 0)
394-
}
395-
return endOffset > offset
396-
? NSRange(location: offset, length: endOffset - offset)
397-
: NSRange(location: endOffset, length: offset - endOffset)
398-
}
399231
}

0 commit comments

Comments
 (0)