Skip to content

Introduce Override Layout Behavior API #84

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
616a48d
Add Overridable Layout To LayoutManager
thecoolwinter Apr 10, 2025
244d59a
Move Layout To Own File
thecoolwinter Apr 10, 2025
c59aef7
File Rename
thecoolwinter Apr 10, 2025
a37a6ae
Update TextLayoutManagerRenderDelegate.swift
thecoolwinter Apr 10, 2025
726b1ec
Move Away from 'drawing' Towards Subclassing `LineFragmentView`
thecoolwinter Apr 10, 2025
d803c7c
Invalidation Performance, Rename LineStorage `index` to `offset`, Edi…
thecoolwinter Apr 11, 2025
949639b
Linter
thecoolwinter Apr 11, 2025
6ca467e
Merge branch 'fix/invalidation' into feat/override-layout-behavior
thecoolwinter Apr 11, 2025
c1dce32
Merge branch 'main' into feat/override-layout-behavior
thecoolwinter Apr 11, 2025
4974ac6
Public Some more Variables, Delegate Estimate Line Height
thecoolwinter Apr 14, 2025
94065e7
Merge branch 'main' into feat/override-layout-behavior
thecoolwinter Apr 14, 2025
ee7024d
Very important Performance Improvements
thecoolwinter Apr 16, 2025
b1450b6
Remembered
thecoolwinter Apr 16, 2025
120e6fc
Add One Case To Layout Invalidation, Selection Drawing Uses LayoutMan…
thecoolwinter Apr 17, 2025
ca32b4c
Document the layout routine
thecoolwinter Apr 17, 2025
537cf74
Move Struct, Update Tests
thecoolwinter Apr 17, 2025
9296067
Add Test
thecoolwinter Apr 17, 2025
e58a836
Remove Methods Destroying Layout Information
thecoolwinter Apr 17, 2025
17b5d98
Update MarkedRanges.swift
thecoolwinter Apr 18, 2025
047d827
Update TextLayoutManager+Layout.swift
thecoolwinter Apr 18, 2025
8db24b1
Update TextLayoutManager+Layout.swift
thecoolwinter Apr 18, 2025
8692f41
Use a Lock
thecoolwinter Apr 18, 2025
fad4ea4
Grammar
thecoolwinter Apr 18, 2025
ef1548b
More Conservative Locking
thecoolwinter Apr 18, 2025
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 @@ -7,7 +7,7 @@

import Foundation

extension NSRange {
public extension NSRange {
@inline(__always)
init(start: Int, end: Int) {
self.init(location: start, length: end - start)
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodeEditTextView/MarkedTextManager/MarkedRanges.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// MarkedRanges.swift
// CodeEditTextView
//
// Created by Khan Winter on 4/17/25.
//

import AppKit

/// Struct for passing attribute and range information easily down into line fragments, typesetters without
/// requiring a reference to the marked text manager.
public struct MarkedRanges {
let ranges: [NSRange]
let attributes: [NSAttributedString.Key: Any]
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@ import AppKit

/// Manages marked ranges. Not a public API.
class MarkedTextManager {
/// Struct for passing attribute and range information easily down into line fragments, typesetters w/o
/// requiring a reference to the marked text manager.
struct MarkedRanges {
let ranges: [NSRange]
let attributes: [NSAttributedString.Key: Any]
}

/// All marked ranges being tracked.
private(set) var markedRanges: [NSRange] = []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,53 @@ extension TextLayoutManager {
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
// MARK: - Layout Lines

/// Lays out all visible lines
func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
assertNotInLayout()
///
/// ## Overview Of The Layout Routine
///
/// The basic premise of this method is that it loops over all lines in the given rect (defaults to the visible
/// rect), checks if the line needs a layout calculation, and performs layout on the line if it does.
///
/// The thing that makes this layout method so fast is the second point, checking if a line needs layout. To
/// determine if a line needs a layout pass, the layout manager can check three things:
/// - **1** Was the line laid out under the assumption of a different maximum layout width?
/// For instance, if a line was previously broken by the line wrapping setting, it won’t need to wrap once the
/// line wrapping is disabled. This will detect that, and cause the lines to be recalculated.
/// - **2** Was the line previously not visible? This is determined by keeping a set of visible line IDs. If the
/// line does not appear in that set, we can assume it was previously off screen and may need layout.
/// - **3** Was the line entirely laid out? We break up lines into line fragments. When we do layout, we determine
/// all line fragments but don't necessarily place them all in the view. This checks if all line fragments have
/// been placed in the view. If not, we need to place them.
///
/// Once it has been determined that a line needs layout, we perform layout by recalculating it's line fragments,
/// removing all old line fragment views, and creating new ones for the line.
///
/// ## Laziness
///
/// At the end of the layout pass, we clean up any old lines by updating the set of visible line IDs and fragment
/// IDs. Any IDs that no longer appear in those sets are removed to save resources. This facilitates the text view's
/// ability to only render text that is visible and saves tons of resources (similar to the lazy loading of
/// collection or table views).
///
/// The other important lazy attribute is the line iteration. Line iteration is done lazily. As we iterate
/// through lines and potentially update their heights, the next line is only queried for *after* the updates are
/// finished.
///
/// ## Reentry
///
/// An important thing to note is that this method cannot be reentered. If a layout pass has begun while a layout
/// pass is already ongoing, internal data structures will be broken. In debug builds, this is checked with a simple
/// boolean and assertion.
///
/// To help ensure this property, all view modifications are performed within a `CATransaction`. This guarantees
/// that macOS calls `layout` on any related views only after we’ve finished inserting and removing line fragment
/// views. Otherwise, inserting a line fragment view could trigger a layout pass prematurely and cause this method
/// to re-enter.
/// - Warning: This is probably not what you're looking for. If you need to invalidate layout, or update lines, this
/// is not the way to do so. This should only be called when macOS performs layout.
public func layoutLines(in rect: NSRect? = nil) { // swiftlint:disable:this function_body_length
guard let visibleRect = rect ?? delegate?.visibleRect,
!isInTransaction,
let textStorage else {
Expand All @@ -38,9 +72,7 @@ extension TextLayoutManager {
// 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
layoutLock.lock()

let minY = max(visibleRect.minY - verticalLayoutPadding, 0)
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
Expand All @@ -53,10 +85,13 @@ extension TextLayoutManager {

// 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) {
guard linePosition.yPos < maxY else { continue }
// Three ways to determine if a line needs to be re-calculated.
let changedWidth = linePosition.data.needsLayout(maxWidth: maxLineLayoutWidth)
let wasNotVisible = !visibleLineIds.contains(linePosition.data.id)
let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height

if forceLayout || changedWidth || wasNotVisible || lineNotEntirelyLaidOut {
let lineSize = layoutLine(
linePosition,
textStorage: textStorage,
Expand Down Expand Up @@ -87,19 +122,19 @@ extension TextLayoutManager {
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.
// The delegate methods below may call another layout pass, make sure we don't send it into a loop of forced
// layout.
needsLayout = false

// Commit the view tree changes we just made.
layoutLock.unlock()
CATransaction.commit()

if maxFoundLineWidth > maxLineWidth {
maxLineWidth = maxFoundLineWidth
Expand All @@ -112,10 +147,10 @@ extension TextLayoutManager {
if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}

needsLayout = false
}

// MARK: - Layout Single Line

/// Lays out a single text line.
/// - Parameters:
/// - position: The line position from storage to use for layout.
Expand All @@ -136,13 +171,24 @@ extension TextLayoutManager {
)

let line = position.data
line.prepareForDisplay(
displayData: lineDisplayData,
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
breakStrategy: lineBreakStrategy
)
if let renderDelegate {
renderDelegate.prepareForDisplay(
textLine: line,
displayData: lineDisplayData,
range: position.range,
stringRef: textStorage,
markedRanges: markedTextManager.markedRanges(in: position.range),
breakStrategy: lineBreakStrategy
)
} else {
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())
Expand All @@ -153,10 +199,11 @@ extension TextLayoutManager {
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
) {
// for lineFragmentPosition in line.lineFragments.linesStartingAt(
// relativeMinY,
// until: relativeMaxY
// ) {
for lineFragmentPosition in line.lineFragments {
let lineFragment = lineFragmentPosition.data

layoutFragmentView(for: lineFragmentPosition, at: position.yPos + lineFragmentPosition.yPos)
Expand All @@ -169,6 +216,8 @@ extension TextLayoutManager {
return CGSize(width: width, height: height)
}

// MARK: - Layout Fragment

/// 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.
Expand All @@ -177,34 +226,13 @@ extension TextLayoutManager {
for lineFragment: TextLineStorage<LineFragment>.TextLinePosition,
at yPos: CGFloat
) {
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id)
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) {
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
}
view.translatesAutoresizingMaskIntoConstraints = false
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 @@ -120,9 +120,6 @@ extension TextLayoutManager {
guard let linePosition = lineStorage.getLine(atOffset: offset) else {
return nil
}
if linePosition.data.lineFragments.isEmpty {
ensureLayoutUntil(offset)
}

guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
atOffset: offset - linePosition.range.location
Expand All @@ -137,15 +134,13 @@ extension TextLayoutManager {
: (textStorage?.string as? NSString)?.rangeOfComposedCharacterSequence(at: offset)
?? NSRange(location: offset, length: 0)

let minXPos = CTLineGetOffsetForStringIndex(
fragmentPosition.data.ctLine,
realRange.location - linePosition.range.location, // CTLines have the same relative range as the line
nil
let minXPos = characterXPosition(
in: fragmentPosition.data,
for: realRange.location - linePosition.range.location
)
let maxXPos = CTLineGetOffsetForStringIndex(
fragmentPosition.data.ctLine,
realRange.max - linePosition.range.location,
nil
let maxXPos = characterXPosition(
in: fragmentPosition.data,
for: realRange.max - linePosition.range.location
)

return CGRect(
Expand All @@ -162,7 +157,6 @@ extension TextLayoutManager {
/// - line: The line to calculate rects for.
/// - Returns: Multiple bounding rects. Will return one rect for each line fragment that overlaps the given range.
public func rectsFor(range: NSRange) -> [CGRect] {
ensureLayoutUntil(range.max)
return lineStorage.linesInRange(range).flatMap { self.rectsFor(range: range, in: $0) }
}

Expand All @@ -187,7 +181,7 @@ extension TextLayoutManager {
var rects: [CGRect] = []
for fragmentPosition in line.data.lineFragments.linesInRange(relativeRange) {
guard let intersectingRange = fragmentPosition.range.intersection(relativeRange) else { continue }
let fragmentRect = fragmentPosition.data.rectFor(range: intersectingRange)
let fragmentRect = characterRect(in: fragmentPosition.data, for: intersectingRange)
guard fragmentRect.width > 0 else { continue }
rects.append(
CGRect(
Expand Down Expand Up @@ -270,35 +264,25 @@ extension TextLayoutManager {
return nil
}

// MARK: - Ensure Layout

/// Forces layout calculation for all lines up to and including the given offset.
/// - Parameter offset: The offset to ensure layout until.
public func ensureLayoutUntil(_ offset: Int) {
guard let linePosition = lineStorage.getLine(atOffset: offset),
let visibleRect = delegate?.visibleRect,
visibleRect.maxY < linePosition.yPos + linePosition.height,
let startingLinePosition = lineStorage.getLine(atPosition: visibleRect.minY)
else {
return
}
let originalHeight = lineStorage.height

for linePosition in lineStorage.linesInRange(
NSRange(start: startingLinePosition.range.location, end: linePosition.range.max)
) {
let height = preparePositionForDisplay(linePosition)
if height != linePosition.height {
lineStorage.update(
atOffset: linePosition.range.location,
delta: 0,
deltaHeight: height - linePosition.height
)
}
}
// MARK: - Line Fragment Rects

if originalHeight != lineStorage.height || layoutView?.frame.size.height != lineStorage.height {
delegate?.layoutManagerHeightDidUpdate(newHeight: lineStorage.height)
}
/// Finds the x position of the offset in the string the fragment represents.
/// - Parameters:
/// - lineFragment: The line fragment to calculate for.
/// - offset: The offset, relative to the start of the *line*.
/// - Returns: The x position of the character in the drawn line, from the left.
public func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat {
renderDelegate?.characterXPosition(in: lineFragment, for: offset) ?? lineFragment._xPos(for: offset)
}

public func characterRect(in lineFragment: LineFragment, for range: NSRange) -> CGRect {
let minXPos = characterXPosition(in: lineFragment, for: range.lowerBound)
let maxXPos = characterXPosition(in: lineFragment, for: range.upperBound)
return CGRect(
x: minXPos,
y: 0,
width: maxXPos - minXPos,
height: lineFragment.scaledHeight
).pixelAligned
}
}
Loading