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
@@ -0,0 +1,14 @@
//
// NSRange+translate.swift
// CodeEditTextView
//
// Created by Khan Winter on 7/21/25.
//

import Foundation

extension NSRange {
func translate(location: Int) -> NSRange {
NSRange(location: self.location + location, length: length)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ extension TextLayoutManager {
linePosition.data.setNeedsLayout()
}

// Special case where we've deleted from the very end, `linesInRange` correctly does not return any lines
// So we need to invalidate the last line specifically.
if range.location == textStorage?.length, !lineStorage.isEmpty {
lineStorage.last?.data.setNeedsLayout()
}

layoutView?.needsLayout = true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ extension TextLayoutManager {
let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0)
let originalHeight = lineStorage.height
var usedFragmentIDs = Set<LineFragment.ID>()
var forceLayout: Bool = needsLayout
let forceLayout: Bool = needsLayout
var didLayoutChange = false
var newVisibleLines: Set<TextLine.ID> = []
var yContentAdjustment: CGFloat = 0
var maxFoundLineWidth = maxLineWidth
Expand All @@ -95,29 +96,17 @@ extension TextLayoutManager {
let wasNotVisible = !visibleLineIds.contains(linePosition.data.id)
let lineNotEntirelyLaidOut = linePosition.height != linePosition.data.lineFragments.height

if forceLayout || linePositionNeedsLayout || wasNotVisible || lineNotEntirelyLaidOut {
let lineSize = layoutLine(
defer { newVisibleLines.insert(linePosition.data.id) }

func fullLineLayout() {
let (yAdjustment, wasLineHeightChanged) = layoutLine(
linePosition,
usedFragmentIDs: &usedFragmentIDs,
textStorage: textStorage,
layoutData: LineLayoutData(minY: minY, maxY: maxY, maxWidth: maxLineLayoutWidth),
laidOutFragmentIDs: &usedFragmentIDs
yRange: minY..<maxY,
maxFoundLineWidth: &maxFoundLineWidth
)
let wasLineHeightChanged = lineSize.height != linePosition.height
if wasLineHeightChanged {
lineStorage.update(
atOffset: linePosition.range.location,
delta: 0,
deltaHeight: lineSize.height - linePosition.height
)

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
}
yContentAdjustment += yAdjustment
#if DEBUG
laidOutLines.insert(linePosition.data.id)
#endif
Expand All @@ -128,12 +117,24 @@ extension TextLayoutManager {
// - New lines being inserted & Lines being deleted (lineNotEntirelyLaidOut)
// - Line updated for width change (wasLineHeightChanged)

forceLayout = forceLayout || wasLineHeightChanged || lineNotEntirelyLaidOut
didLayoutChange = didLayoutChange || wasLineHeightChanged || lineNotEntirelyLaidOut
}

if forceLayout || linePositionNeedsLayout || wasNotVisible || lineNotEntirelyLaidOut {
fullLineLayout()
} else {
if didLayoutChange || yContentAdjustment > 0 {
// Layout happened and this line needs to be moved but not necessarily re-added
let needsFullLayout = updateLineViewPositions(linePosition)
if needsFullLayout {
fullLineLayout()
continue
}
}

// Make sure the used fragment views aren't dequeued.
usedFragmentIDs.formUnion(linePosition.data.lineFragments.map(\.data.id))
}
newVisibleLines.insert(linePosition.data.id)
}

// Enqueue any lines not used in this layout pass.
Expand Down Expand Up @@ -171,14 +172,50 @@ extension TextLayoutManager {

// MARK: - Layout Single Line

private func layoutLine(
_ linePosition: TextLineStorage<TextLine>.TextLinePosition,
usedFragmentIDs: inout Set<LineFragment.ID>,
textStorage: NSTextStorage,
yRange: Range<CGFloat>,
maxFoundLineWidth: inout CGFloat
) -> (CGFloat, wasLineHeightChanged: Bool) {
let lineSize = layoutLineViews(
linePosition,
textStorage: textStorage,
layoutData: LineLayoutData(minY: yRange.lowerBound, maxY: yRange.upperBound, maxWidth: maxLineLayoutWidth),
laidOutFragmentIDs: &usedFragmentIDs
)
let wasLineHeightChanged = lineSize.height != linePosition.height
var yContentAdjustment: CGFloat = 0.0
var maxFoundLineWidth = maxFoundLineWidth

if wasLineHeightChanged {
lineStorage.update(
atOffset: linePosition.range.location,
delta: 0,
deltaHeight: lineSize.height - linePosition.height
)

if linePosition.yPos < yRange.lowerBound {
// 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
}

return (yContentAdjustment, wasLineHeightChanged)
}

/// 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(
private func layoutLineViews(
_ position: TextLineStorage<TextLine>.TextLinePosition,
textStorage: NSTextStorage,
layoutData: LineLayoutData,
Expand Down Expand Up @@ -226,8 +263,13 @@ extension TextLayoutManager {
// ) {
for lineFragmentPosition in line.lineFragments {
let lineFragment = lineFragmentPosition.data
lineFragment.documentRange = lineFragmentPosition.range.translate(location: position.range.location)

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

width = max(width, lineFragment.width)
height += lineFragment.scaledHeight
Expand All @@ -244,16 +286,32 @@ extension TextLayoutManager {
/// - lineFragment: The line fragment position to lay out a view for.
/// - yPos: The y value at which the line should begin.
private func layoutFragmentView(
inLine line: TextLineStorage<TextLine>.TextLinePosition,
for lineFragment: TextLineStorage<LineFragment>.TextLinePosition,
at yPos: CGFloat
) {
let fragmentRange = lineFragment.range.translate(location: line.range.location)
let view = viewReuseQueue.getOrCreateView(forKey: lineFragment.data.id) {
renderDelegate?.lineFragmentView(for: lineFragment.data) ?? LineFragmentView()
}
view.translatesAutoresizingMaskIntoConstraints = true // Small optimization for lots of subviews
view.setLineFragment(lineFragment.data, renderer: lineFragmentRenderer)
view.setLineFragment(lineFragment.data, fragmentRange: fragmentRange, renderer: lineFragmentRenderer)
view.frame.origin = CGPoint(x: edgeInsets.left, y: yPos)
layoutView?.addSubview(view, positioned: .below, relativeTo: nil)
view.needsDisplay = true
}

private func updateLineViewPositions(_ position: TextLineStorage<TextLine>.TextLinePosition) -> Bool {
let line = position.data
for lineFragmentPosition in line.lineFragments {
guard let view = viewReuseQueue.getView(forKey: lineFragmentPosition.data.id) else {
return true
}
lineFragmentPosition.data.documentRange = lineFragmentPosition.range.translate(
location: position.range.location
)
view.frame.origin = CGPoint(x: edgeInsets.left, y: position.yPos + lineFragmentPosition.yPos)
}
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,13 @@ extension TextLayoutManager {
fragmentPosition: TextLineStorage<LineFragment>.TextLinePosition,
in linePosition: TextLineStorage<TextLine>.TextLinePosition
) -> Int? {
let endPosition = fragmentPosition.data.documentRange.max
let fragmentRange = fragmentPosition.range.translate(location: linePosition.range.location)
let endPosition = fragmentRange.max

// If the endPosition is at the end of the line, and the line ends with a line ending character
// return the index before the eol.
if fragmentPosition.index == linePosition.data.lineFragments.count - 1,
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentPosition.data.documentRange) ?? "") {
let lineEnding = LineEnding(line: textStorage?.substring(from: fragmentRange) ?? "") {
return endPosition - lineEnding.length
} else if fragmentPosition.index != linePosition.data.lineFragments.count - 1 {
// If this isn't the last fragment, we want to place the cursor at the offset right before the break
Expand Down Expand Up @@ -175,7 +176,7 @@ extension TextLayoutManager {
guard let fragmentPosition = linePosition.data.typesetter.lineFragments.getLine(
atOffset: offset - linePosition.range.location
) else {
return nil
return CGRect(x: edgeInsets.left, y: linePosition.yPos, width: 0, height: linePosition.height)
}

// Get the *real* length of the character at the offset. If this is a surrogate pair it'll return the correct
Expand All @@ -190,11 +191,11 @@ extension TextLayoutManager {

let minXPos = characterXPosition(
in: fragmentPosition.data,
for: realRange.location - fragmentPosition.data.documentRange.location
for: realRange.location - linePosition.range.location - fragmentPosition.range.location
)
let maxXPos = characterXPosition(
in: fragmentPosition.data,
for: realRange.max - fragmentPosition.data.documentRange.location
for: realRange.max - linePosition.range.location - fragmentPosition.range.location
)

return CGRect(
Expand Down
7 changes: 1 addition & 6 deletions Sources/CodeEditTextView/TextLine/LineFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ public final class LineFragment: Identifiable, Equatable {
}

public let id = UUID()
public let lineRange: NSRange
public let documentRange: NSRange
public var documentRange: NSRange = .notFound
public var contents: [FragmentContent]
public var width: CGFloat
public var height: CGFloat
Expand All @@ -61,16 +60,12 @@ public final class LineFragment: Identifiable, Equatable {
}

init(
lineRange: NSRange,
documentRange: NSRange,
contents: [FragmentContent],
width: CGFloat,
height: CGFloat,
descent: CGFloat,
lineHeightMultiplier: CGFloat
) {
self.lineRange = lineRange
self.documentRange = documentRange
self.contents = contents
self.width = width
self.height = height
Expand Down
4 changes: 2 additions & 2 deletions Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public final class LineFragmentRenderer {
context: context
)

let range = createTextRange(for: drawingContext)
let range = createTextRange(for: drawingContext).clamped(to: (textStorage.string as NSString).length)
let string = (textStorage.string as NSString).substring(with: range)

processInvisibleCharacters(
Expand Down Expand Up @@ -177,7 +177,7 @@ public final class LineFragmentRenderer {
guard let style = delegate.invisibleStyle(
for: character,
at: NSRange(start: range.location + index, end: range.max),
lineRange: drawingContext.lineFragment.lineRange
lineRange: drawingContext.lineFragment.documentRange
) else {
return
}
Expand Down
47 changes: 45 additions & 2 deletions Sources/CodeEditTextView/TextLine/LineFragmentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import AppKit
open class LineFragmentView: NSView {
public weak var lineFragment: LineFragment?
public weak var renderer: LineFragmentRenderer?
#if DEBUG_LINE_INVALIDATION
private var backgroundAnimation: CABasicAnimation?
#endif

open override var isFlipped: Bool {
true
Expand All @@ -22,15 +25,55 @@ open class LineFragmentView: NSView {

open override func hitTest(_ point: NSPoint) -> NSView? { nil }

/// Prepare the view for reuse, clears the line fragment reference.
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}

required public init?(coder: NSCoder) {
super.init(coder: coder)
}

#if DEBUG_LINE_INVALIDATION
/// Setup background animation from random color to clear when this fragment is invalidated.
private func setupBackgroundAnimation() {
self.wantsLayer = true

let randomColor = NSColor(
red: CGFloat.random(in: 0...1),
green: CGFloat.random(in: 0...1),
blue: CGFloat.random(in: 0...1),
alpha: 0.3
)

self.layer?.backgroundColor = randomColor.cgColor

let animation = CABasicAnimation(keyPath: "backgroundColor")
animation.fromValue = randomColor.cgColor
animation.toValue = NSColor.clear.cgColor
animation.duration = 1.0
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
self.layer?.add(animation, forKey: "backgroundColorAnimation")

DispatchQueue.main.asyncAfter(deadline: .now() + animation.duration) {
self.layer?.backgroundColor = NSColor.clear.cgColor
}
}
#endif

open override func prepareForReuse() {
super.prepareForReuse()
lineFragment = nil

#if DEBUG_LINE_INVALIDATION
setupBackgroundAnimation()
#endif
}

/// Set a new line fragment for this view, updating view size.
/// - Parameter newFragment: The new fragment to use.
open func setLineFragment(_ newFragment: LineFragment, renderer: LineFragmentRenderer) {
open func setLineFragment(_ newFragment: LineFragment, fragmentRange: NSRange, renderer: LineFragmentRenderer) {
self.lineFragment = newFragment
self.renderer = renderer
self.frame.size = CGSize(width: newFragment.width, height: newFragment.scaledHeight)
Expand Down
6 changes: 2 additions & 4 deletions Sources/CodeEditTextView/TextLine/TextLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ public final class TextLine: Identifiable, Equatable {
// Both max widths we're comparing are finite
maxWidth.isFinite
&& (self.maxWidth ?? 0.0).isFinite
// We can't use `<` here because we want to calculate layout again if this was previously constrained to a
// small layout size and needs to grow.
&& maxWidth != (self.maxWidth ?? 0.0)
)
}
Expand All @@ -57,14 +55,14 @@ public final class TextLine: Identifiable, Equatable {
attachments: [AnyTextAttachment]
) {
let string = stringRef.attributedSubstring(from: range)
self.maxWidth = displayData.maxWidth
typesetter.typeset(
let maxWidth = typesetter.typeset(
string,
documentRange: range,
displayData: displayData,
markedRanges: markedRanges,
attachments: attachments
)
self.maxWidth = displayData.maxWidth
needsLayout = false
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@ struct TypesetContext {
/// Pop the current fragment state into a new line fragment, and reset the fragment state.
mutating func popCurrentData() {
let fragment = LineFragment(
lineRange: documentRange,
documentRange: NSRange(
location: fragmentContext.start + documentRange.location,
length: currentPosition - fragmentContext.start
),
contents: fragmentContext.contents,
width: fragmentContext.width,
height: fragmentContext.height,
Expand Down
Loading