Skip to content
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 @@ -45,7 +45,8 @@ extension TextLayoutManager: NSTextStorageDelegate {
let insertedStringRange = NSRange(location: editedRange.location, length: editedRange.length - delta)
removeLayoutLinesIn(range: insertedStringRange)
insertNewLines(for: editedRange)
invalidateLayoutForRange(editedRange)

setNeedsLayout()
}

/// Removes all lines in the range, as if they were deleted. This is a setup for inserting the lines back in on an
Expand All @@ -65,10 +66,10 @@ extension TextLayoutManager: NSTextStorageDelegate {
lineStorage.delete(lineAt: nextLine.range.location)
let delta = -intersection.length + nextLine.range.length
if delta != 0 {
lineStorage.update(atIndex: linePosition.range.location, delta: delta, deltaHeight: 0)
lineStorage.update(atOffset: linePosition.range.location, delta: delta, deltaHeight: 0)
}
} else {
lineStorage.update(atIndex: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
lineStorage.update(atOffset: linePosition.range.location, delta: -intersection.length, deltaHeight: 0)
}
}
}
Expand Down Expand Up @@ -100,7 +101,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
if location == lineStorage.length {
// Insert a new line at the end of the document, need to insert a new line 'cause there's nothing to
// split. Also, append the new text to the last line.
lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0)
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
lineStorage.insert(
line: TextLine(),
atOffset: location + insertedString.length,
Expand All @@ -114,7 +115,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
let splitLength = linePosition.range.max - location
let lineDelta = insertedString.length - splitLength // The difference in the line being edited
if lineDelta != 0 {
lineStorage.update(atIndex: location, delta: lineDelta, deltaHeight: 0.0)
lineStorage.update(atOffset: location, delta: lineDelta, deltaHeight: 0.0)
}

lineStorage.insert(
Expand All @@ -125,7 +126,7 @@ extension TextLayoutManager: NSTextStorageDelegate {
)
}
} else {
lineStorage.update(atIndex: location, delta: insertedString.length, deltaHeight: 0.0)
lineStorage.update(atOffset: location, delta: insertedString.length, deltaHeight: 0.0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ extension TextLayoutManager {
for linePosition in lineStorage.linesStartingAt(rect.minY, until: rect.maxY) {
linePosition.data.setNeedsLayout()
}
layoutLines()

layoutView?.needsLayout = true
}

/// Invalidates layout for the given range of text.
Expand All @@ -24,11 +25,12 @@ extension TextLayoutManager {
linePosition.data.setNeedsLayout()
}

layoutLines()
layoutView?.needsLayout = true
}

public func setNeedsLayout() {
needsLayout = true
visibleLineIds.removeAll(keepingCapacity: true)
layoutView?.needsLayout = true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//
// TextLayoutManager+ensureLayout.swift
// CodeEditTextView
//
// Created by Khan Winter on 4/7/25.
//

import AppKit

extension TextLayoutManager {
/// Contains all data required to perform layout on a text line.
private struct LineLayoutData {
let minY: CGFloat
let maxY: CGFloat
let maxWidth: CGFloat
}

/// Asserts that the caller is not in an active layout pass.
/// See docs on ``isInLayout`` for more details.
private func assertNotInLayout() {
#if DEBUG // This is redundant, but it keeps the flag debug-only too which helps prevent misuse.
assert(!isInLayout, "layoutLines called while already in a layout pass. This is a programmer error.")
#endif
}

// MARK: - Layout

/// Lays out all visible lines
func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
assertNotInLayout()
guard let visibleRect = rect ?? delegate?.visibleRect,
!isInTransaction,
let textStorage else {
return
}

// The macOS may call `layout` on the textView while we're laying out fragment views. This ensures the view
// tree modifications caused by this method are atomic, so macOS won't call `layout` while we're already doing
// that
CATransaction.begin()
#if DEBUG
isInLayout = true
#endif

let minY = max(visibleRect.minY - verticalLayoutPadding, 0)
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
let originalHeight = lineStorage.height
var usedFragmentIDs = Set<UUID>()
var forceLayout: Bool = needsLayout
var newVisibleLines: Set<TextLine.ID> = []
var yContentAdjustment: CGFloat = 0
var maxFoundLineWidth = maxLineWidth

// 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: minY, maxY: maxY, maxWidth: maxLineLayoutWidth),
laidOutFragmentIDs: &usedFragmentIDs
)
if lineSize.height != linePosition.height {
lineStorage.update(
atOffset: linePosition.range.location,
delta: 0,
deltaHeight: lineSize.height - linePosition.height
)
// If we've updated a line's height, force re-layout for the rest of the pass.
forceLayout = true

if linePosition.yPos < minY {
// Adjust the scroll position by the difference between the new height and old.
yContentAdjustment += lineSize.height - linePosition.height
}
}
if maxFoundLineWidth < lineSize.width {
maxFoundLineWidth = lineSize.width
}
} else {
// Make sure the used fragment views aren't dequeued.
usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id))
}
newVisibleLines.insert(linePosition.data.id)
}

#if DEBUG
isInLayout = false
#endif
CATransaction.commit()

// Enqueue any lines not used in this layout pass.
viewReuseQueue.enqueueViews(notInSet: usedFragmentIDs)

// Update the visible lines with the new set.
visibleLineIds = newVisibleLines

// These are fine to update outside of `isInLayout` as our internal data structures are finalized at this point
// so laying out again won't break our line storage or visible line.

if maxFoundLineWidth > maxLineWidth {
maxLineWidth = maxFoundLineWidth
}

if yContentAdjustment != 0 {
delegate?.layoutManagerYAdjustment(yContentAdjustment)
}

if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}

needsLayout = false
}

/// Lays out a single text line.
/// - Parameters:
/// - position: The line position from storage to use for layout.
/// - textStorage: The text storage object to use for text info.
/// - 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(
_ position: TextLineStorage<TextLine>.TextLinePosition,
textStorage: NSTextStorage,
layoutData: LineLayoutData,
laidOutFragmentIDs: inout Set<UUID>
) -> CGSize {
let lineDisplayData = TextLine.DisplayData(
maxWidth: layoutData.maxWidth,
lineHeightMultiplier: lineHeightMultiplier,
estimatedLineHeight: estimateLineHeight()
)

let line = position.data
line.prepareForDisplay(
displayData: lineDisplayData,
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
breakStrategy: lineBreakStrategy
)

if position.range.isEmpty {
return CGSize(width: 0, height: estimateLineHeight())
}

var height: CGFloat = 0
var width: CGFloat = 0
let relativeMinY = max(layoutData.minY - position.yPos, 0)
let relativeMaxY = max(layoutData.maxY - position.yPos, relativeMinY)

for lineFragmentPosition in line.lineFragments.linesStartingAt(
relativeMinY,
until: relativeMaxY
) {
let lineFragment = lineFragmentPosition.data

layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos)

width = max(width, lineFragment.width)
height += lineFragment.scaledHeight
laidOutFragmentIDs.insert(lineFragment.id)
}

return CGSize(width: width, height: height)
}

/// Lays out a line fragment view for the given line fragment at the specified y value.
/// - Parameters:
/// - lineFragment: The line fragment position to lay out a view for.
/// - yPos: The y value at which the line should begin.
private func layoutFragmentView(
for lineFragment: TextLineStorage<LineFragment>.TextLinePosition,
at yPos: CGFloat
) {
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
view.setLineFragment(lineFragment.data)
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
layoutView?.addSubview(view)
view.needsDisplay = true
}

/// Invalidates and prepares a line position for display.
/// - Parameter position: The line position to prepare.
/// - Returns: The height of the newly laid out line and all it's fragments.
func preparePositionForDisplay(_ position: TextLineStorage<TextLine>.TextLinePosition) -> CGFloat {
guard let textStorage else { return 0 }
let displayData = TextLine.DisplayData(
maxWidth: maxLineLayoutWidth,
lineHeightMultiplier: lineHeightMultiplier,
estimatedLineHeight: estimateLineHeight()
)
position.data.prepareForDisplay(
displayData: displayData,
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
breakStrategy: lineBreakStrategy
)
var height: CGFloat = 0
for fragmentPosition in position.data.lineFragments {
height += fragmentPosition.data.scaledHeight
}
return height
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,7 @@ extension TextLayoutManager {
return nil
}
if linePosition.data.lineFragments.isEmpty {
let newHeight = preparePositionForDisplay(linePosition)
if linePosition.height != newHeight {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
ensureLayoutUntil(offset)
}

guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
Expand Down Expand Up @@ -293,7 +290,7 @@ extension TextLayoutManager {
let height = preparePositionForDisplay(linePosition)
if height != linePosition.height {
lineStorage.update(
atIndex: linePosition.range.location,
atOffset: linePosition.range.location,
delta: 0,
deltaHeight: height - linePosition.height
)
Expand Down

This file was deleted.

Loading