From e7f1580a8075af84c349fb8c66fbd2776ff5cb1d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 23 Jul 2025 12:21:35 -0500 Subject: [PATCH] Layout Invalidation Performance (#118) ### Description Made the layout manager *move* lines that weren't invalidated instead of re-typesetting them. Also made the reuse queue just make views tiny and hide them instead of removing them from the view hierarchy, and queue more views. After these changes, layout in the source editor goes from taking up *36% of the CPU* time while editing, to taking up **5% of main thread CPU time**. - Added a new debug mode for visualizing line fragment invalidation. - Adjusted the layout pass to avoid typesetting lines that don't need layout. - Made a distinction between 'forced layout' via `setNeedsLayout` and 'continued' layout where a line previously scanned in the layout pass was updated. - Due to that, I was able to check if a line fragment actually needed typesetting or just potentially needed to have it's position adjusted. - Added a new method to update a line's view's positions during layout. - Removed the unnecessary `lineRange` variable on the `LineFragment` class. - Adjusted the use of `documentRange` on the `LineFragment` class. It's now updated during layout, simplifying various methods. ### Related Issues * N/A ### Checklist - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots Before: https://github.com/user-attachments/assets/05027712-5690-4970-b1ab-e0b4fe9553ec After: https://github.com/user-attachments/assets/36350ea1-66d8-43d0-a676-1bb770a733d7 --- .../NSRange+/NSRange+translate.swift | 14 +++ .../TextLayoutManager+Invalidation.swift | 6 + .../TextLayoutManager+Layout.swift | 110 +++++++++++++----- .../TextLayoutManager+Public.swift | 11 +- .../TextLine/LineFragment.swift | 7 +- .../TextLine/LineFragmentRenderer.swift | 4 +- .../TextLine/LineFragmentView.swift | 47 +++++++- .../CodeEditTextView/TextLine/TextLine.swift | 6 +- .../TextLine/Typesetter/TypesetContext.swift | 5 - .../TextLine/Typesetter/Typesetter.swift | 2 - .../TextView/TextView+ScrollToVisible.swift | 9 +- .../Utils/ViewReuseQueue.swift | 12 +- .../TextLayoutManagerTests.swift | 25 +++- .../TypesetterTests.swift | 3 - 14 files changed, 197 insertions(+), 64 deletions(-) create mode 100644 Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift diff --git a/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift new file mode 100644 index 000000000..40f6251cc --- /dev/null +++ b/Sources/CodeEditTextView/Extensions/NSRange+/NSRange+translate.swift @@ -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) + } +} diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift index 24fec8074..6b13819c6 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Invalidation.swift @@ -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 } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift index 074d8fcef..acf0ea0ae 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Layout.swift @@ -79,7 +79,8 @@ extension TextLayoutManager { let maxY = max(visibleRect.maxY + verticalLayoutPadding, 0) let originalHeight = lineStorage.height var usedFragmentIDs = Set() - var forceLayout: Bool = needsLayout + let forceLayout: Bool = needsLayout + var didLayoutChange = false var newVisibleLines: Set = [] var yContentAdjustment: CGFloat = 0 var maxFoundLineWidth = maxLineWidth @@ -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.. 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. @@ -171,6 +172,42 @@ extension TextLayoutManager { // MARK: - Layout Single Line + private func layoutLine( + _ linePosition: TextLineStorage.TextLinePosition, + usedFragmentIDs: inout Set, + textStorage: NSTextStorage, + yRange: Range, + 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. @@ -178,7 +215,7 @@ extension TextLayoutManager { /// - 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.TextLinePosition, textStorage: NSTextStorage, layoutData: LineLayoutData, @@ -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 @@ -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.TextLinePosition, for lineFragment: TextLineStorage.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.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 + } } diff --git a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift index e05c01c20..b73c17177 100644 --- a/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift +++ b/Sources/CodeEditTextView/TextLayoutManager/TextLayoutManager+Public.swift @@ -112,12 +112,13 @@ extension TextLayoutManager { fragmentPosition: TextLineStorage.TextLinePosition, in linePosition: TextLineStorage.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 @@ -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 @@ -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( diff --git a/Sources/CodeEditTextView/TextLine/LineFragment.swift b/Sources/CodeEditTextView/TextLine/LineFragment.swift index 6671fb8ef..646bf76ba 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragment.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragment.swift @@ -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 @@ -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 diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift index 4824e010b..6330d0ee0 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentRenderer.swift @@ -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( @@ -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 } diff --git a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift index 58a793306..66af42872 100644 --- a/Sources/CodeEditTextView/TextLine/LineFragmentView.swift +++ b/Sources/CodeEditTextView/TextLine/LineFragmentView.swift @@ -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 @@ -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) diff --git a/Sources/CodeEditTextView/TextLine/TextLine.swift b/Sources/CodeEditTextView/TextLine/TextLine.swift index 2eee6f375..b9038a6f3 100644 --- a/Sources/CodeEditTextView/TextLine/TextLine.swift +++ b/Sources/CodeEditTextView/TextLine/TextLine.swift @@ -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) ) } @@ -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 } diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift index e74af78a9..f5b6ab6df 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/TypesetContext.swift @@ -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, diff --git a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift index 2bebfb69d..b5edb8594 100644 --- a/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift +++ b/Sources/CodeEditTextView/TextLine/Typesetter/Typesetter.swift @@ -230,8 +230,6 @@ final public class Typesetter { // Insert an empty fragment let ctLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, 0)) let fragment = LineFragment( - lineRange: documentRange ?? .zero, - documentRange: NSRange(location: (documentRange ?? .notFound).location, length: 0), contents: [.init(data: .text(line: ctLine), width: 0.0)], width: 0, height: displayData.estimatedLineHeight / displayData.lineHeightMultiplier, diff --git a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift index c4cf1fa6c..bc5274ed3 100644 --- a/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift +++ b/Sources/CodeEditTextView/TextView/TextView+ScrollToVisible.swift @@ -29,10 +29,11 @@ extension TextView { layoutManager.layoutLines() selectionManager.updateSelectionViews() selectionManager.drawSelections(in: visibleRect) - } - if lastFrame != .zero { - scrollView.contentView.scrollToVisible(lastFrame) - scrollView.reflectScrolledClipView(scrollView.contentView) + + if lastFrame != .zero { + scrollView.contentView.scrollToVisible(lastFrame) + scrollView.reflectScrolledClipView(scrollView.contentView) + } } } diff --git a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift index 15a65d014..3b85c6aeb 100644 --- a/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift +++ b/Sources/CodeEditTextView/Utils/ViewReuseQueue.swift @@ -35,20 +35,28 @@ public class ViewReuseQueue { } else { view = queuedViews.popFirst() ?? createView() view.prepareForReuse() + view.isHidden = false usedViews[key] = view } return view } + public func getView(forKey key: Key) -> View? { + usedViews[key] + } + /// Removes a view for the given key and enqueues it for reuse. /// - Parameter key: The key for the view to reuse. public func enqueueView(forKey key: Key) { guard let view = usedViews[key] else { return } - if queuedViews.count < usedViews.count / 4 { + if queuedViews.count < usedViews.count { queuedViews.append(view) + view.frame = .zero + view.isHidden = true + } else { + view.removeFromSuperviewWithoutNeedingDisplay() } usedViews.removeValue(forKey: key) - view.removeFromSuperviewWithoutNeedingDisplay() } /// Enqueues all views not in the given set. diff --git a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift index 3baab6c3a..f40c1b878 100644 --- a/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift +++ b/Tests/CodeEditTextViewTests/LayoutManager/TextLayoutManagerTests.swift @@ -212,15 +212,18 @@ struct TextLayoutManagerTests { ) } - /// Inserting a new line should cause layout going down the rest of the screen, because the following lines - /// should have moved their position to accomodate the new line. + /// ~~Inserting a new line should cause layout going down the rest of the screen, because the following lines + /// should have moved their position to accomodate the new line.~~ + /// This is slightly changed now. The layout manager checks if a line actually needs to be typeset again and only + /// invalidates it if it does. Otherwise it moves lines. This test now just checks that the invalidated lines + /// equal the expected invalidated lines. @Test func editsWithNewlinesForceLayoutGoingDownScreen() { layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) textStorage.replaceCharacters(in: NSRange(start: 4, end: 4), with: "Z\n") let expectedLineIds = Array( - layoutManager.lineStorage.linesInRange(NSRange(location: 4, length: 9)) + layoutManager.lineStorage.linesInRange(NSRange(location: 4, length: 4)) ).map { $0.data.id } #expect(layoutManager.needsLayout == false) // No forced layout for entire view @@ -250,4 +253,20 @@ struct TextLayoutManagerTests { } } } + + @Test + func editingEndOfDocumentInvalidatesLastLine() throws { + // Setup a slightly longer final line + textStorage.replaceCharacters(in: NSRange(location: 7, length: 0), with: "EFGH") + layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + textStorage.replaceCharacters(in: NSRange(location: 10, length: 1), with: "") + let invalidatedLineIds = layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1000, height: 1000)) + + let expectedLineIds = Array( + layoutManager.lineStorage.linesInRange(NSRange(location: 6, length: 0)) + ).map { $0.data.id } + + #expect(invalidatedLineIds.isSuperset(of: Set(expectedLineIds))) + } } diff --git a/Tests/CodeEditTextViewTests/TypesetterTests.swift b/Tests/CodeEditTextViewTests/TypesetterTests.swift index d671ea6ff..92826c365 100644 --- a/Tests/CodeEditTextViewTests/TypesetterTests.swift +++ b/Tests/CodeEditTextViewTests/TypesetterTests.swift @@ -254,17 +254,14 @@ class TypesetterTests: XCTestCase { XCTAssertEqual(typesetter.lineFragments.count, 3) var fragment = try XCTUnwrap(typesetter.lineFragments.first?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 0, length: 1)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertTrue(fragment.contents[0].isText) fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 1)?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 1, length: 1)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertFalse(fragment.contents[0].isText) fragment = try XCTUnwrap(typesetter.lineFragments.getLine(atIndex: 2)?.data) - XCTAssertEqual(fragment.documentRange, NSRange(location: 2, length: 4)) XCTAssertEqual(fragment.contents.count, 1) XCTAssertTrue(fragment.contents[0].isText) }