perf(editor): implement dirty-line invalidation

This commit is contained in:
Feror 2026-05-29 20:57:03 +02:00
parent 6b1d2f8b27
commit 6aefffef8d
7 changed files with 649 additions and 52 deletions

View file

@ -0,0 +1,140 @@
import Foundation
public struct EditorDirtyLineInvalidationPlan: Hashable, Sendable {
public var reason: EditorRenderReason
public var isFullRender: Bool
public var dirtyLineIndexes: [Int]
public var changedRange: NSRange?
public init(
reason: EditorRenderReason,
isFullRender: Bool,
dirtyLineIndexes: [Int],
changedRange: NSRange? = nil
) {
self.reason = reason
self.isFullRender = isFullRender
self.dirtyLineIndexes = dirtyLineIndexes
self.changedRange = changedRange
}
public var dirtyLineCount: Int {
dirtyLineIndexes.count
}
public var requiresStyling: Bool {
isFullRender || !dirtyLineIndexes.isEmpty
}
}
public enum EditorDirtyLineInvalidator {
public static func plan(
previousText: String?,
currentText: String,
previousActiveLineIndex: Int?,
currentActiveLineIndex: Int
) -> EditorDirtyLineInvalidationPlan {
let currentLines = EditorActiveLineTracker.lines(
from: currentText,
activeLineIndex: currentActiveLineIndex
)
guard let previousText else {
return EditorDirtyLineInvalidationPlan(
reason: .initial,
isFullRender: true,
dirtyLineIndexes: currentLines.map(\.index)
)
}
var dirtyLineIndexes = Set<Int>()
let reason: EditorRenderReason
var changedRange: NSRange?
if previousText != currentText {
reason = .sourceChange
changedRange = changedUTF16Range(from: previousText, to: currentText)
dirtyLineIndexes.formUnion(
lineIndexesAffected(
by: changedRange ?? NSRange(location: 0, length: currentText.utf16.count),
in: currentText,
lineCount: currentLines.count
)
)
} else if previousActiveLineIndex != currentActiveLineIndex {
reason = .activeLineChange
} else {
reason = .viewUpdate
}
if let previousActiveLineIndex,
previousActiveLineIndex != currentActiveLineIndex {
dirtyLineIndexes.insert(previousActiveLineIndex)
dirtyLineIndexes.insert(currentActiveLineIndex)
}
let validLineIndexes = dirtyLineIndexes
.filter { (0..<currentLines.count).contains($0) }
.sorted()
return EditorDirtyLineInvalidationPlan(
reason: reason,
isFullRender: false,
dirtyLineIndexes: validLineIndexes,
changedRange: changedRange
)
}
private static func changedUTF16Range(from oldText: String, to newText: String) -> NSRange {
let old = oldText as NSString
let new = newText as NSString
let commonLength = min(old.length, new.length)
var prefixLength = 0
while prefixLength < commonLength,
old.character(at: prefixLength) == new.character(at: prefixLength) {
prefixLength += 1
}
var oldSuffixStart = old.length
var newSuffixStart = new.length
while oldSuffixStart > prefixLength,
newSuffixStart > prefixLength,
old.character(at: oldSuffixStart - 1) == new.character(at: newSuffixStart - 1) {
oldSuffixStart -= 1
newSuffixStart -= 1
}
return NSRange(location: prefixLength, length: max(0, newSuffixStart - prefixLength))
}
private static func lineIndexesAffected(
by changedRange: NSRange,
in source: String,
lineCount: Int
) -> Set<Int> {
guard lineCount > 0 else { return [] }
let sourceLength = source.utf16.count
let startLocation = max(0, min(changedRange.location, sourceLength))
let endLocation: Int
if changedRange.length == 0 {
endLocation = startLocation
} else {
endLocation = max(startLocation, min(changedRange.upperBound - 1, sourceLength))
}
let startLine = EditorActiveLineTracker.lineIndex(containing: startLocation, in: source)
let endLine = EditorActiveLineTracker.lineIndex(containing: endLocation, in: source)
let lowerBound = max(0, min(startLine, endLine) - 1)
let upperBound = min(lineCount - 1, max(startLine, endLine) + 1)
return Set(lowerBound...upperBound)
}
}
private extension NSRange {
var upperBound: Int {
location + length
}
}

View file

@ -12,53 +12,77 @@ public struct EditorRenderPassMetric: Hashable, Sendable {
public var durationMilliseconds: Double
public var characterCount: Int
public var lineCount: Int
public var dirtyLineCount: Int
public var activeLineIndex: Int
public var isFullRender: Bool
public var restoredScrollPosition: Bool
public init(
reason: EditorRenderReason,
durationMilliseconds: Double,
characterCount: Int,
lineCount: Int,
activeLineIndex: Int
dirtyLineCount: Int = 0,
activeLineIndex: Int,
isFullRender: Bool = false,
restoredScrollPosition: Bool = false
) {
self.reason = reason
self.durationMilliseconds = durationMilliseconds
self.characterCount = characterCount
self.lineCount = lineCount
self.dirtyLineCount = dirtyLineCount
self.activeLineIndex = activeLineIndex
self.isFullRender = isFullRender
self.restoredScrollPosition = restoredScrollPosition
}
}
public struct EditorInstrumentationSnapshot: Hashable, Sendable {
public var sourceChangeCount: Int
public var selectionChangeCount: Int
public var selectionUpdateCount: Int
public var activeLineChangeCount: Int
public var renderPassCount: Int
public var fullRenderCount: Int
public var totalDirtyLineCount: Int
public var scrollRestorationCount: Int
public var totalRenderDurationMilliseconds: Double
public var lastRenderDurationMilliseconds: Double
public var lastRenderCharacterCount: Int
public var lastRenderLineCount: Int
public var lastDirtyLineCount: Int
public var lastRenderReason: EditorRenderReason?
public init(
sourceChangeCount: Int = 0,
selectionChangeCount: Int = 0,
selectionUpdateCount: Int = 0,
activeLineChangeCount: Int = 0,
renderPassCount: Int = 0,
fullRenderCount: Int = 0,
totalDirtyLineCount: Int = 0,
scrollRestorationCount: Int = 0,
totalRenderDurationMilliseconds: Double = 0,
lastRenderDurationMilliseconds: Double = 0,
lastRenderCharacterCount: Int = 0,
lastRenderLineCount: Int = 0,
lastDirtyLineCount: Int = 0,
lastRenderReason: EditorRenderReason? = nil
) {
self.sourceChangeCount = sourceChangeCount
self.selectionChangeCount = selectionChangeCount
self.selectionUpdateCount = selectionUpdateCount
self.activeLineChangeCount = activeLineChangeCount
self.renderPassCount = renderPassCount
self.fullRenderCount = fullRenderCount
self.totalDirtyLineCount = totalDirtyLineCount
self.scrollRestorationCount = scrollRestorationCount
self.totalRenderDurationMilliseconds = totalRenderDurationMilliseconds
self.lastRenderDurationMilliseconds = lastRenderDurationMilliseconds
self.lastRenderCharacterCount = lastRenderCharacterCount
self.lastRenderLineCount = lastRenderLineCount
self.lastDirtyLineCount = lastDirtyLineCount
self.lastRenderReason = lastRenderReason
}
@ -73,6 +97,7 @@ public struct EditorInstrumentationSnapshot: Hashable, Sendable {
public mutating func recordSelectionChange() {
selectionChangeCount += 1
selectionUpdateCount += 1
}
public mutating func recordActiveLineChange() {
@ -81,10 +106,38 @@ public struct EditorInstrumentationSnapshot: Hashable, Sendable {
public mutating func recordRenderPass(_ metric: EditorRenderPassMetric) {
renderPassCount += 1
if metric.isFullRender {
fullRenderCount += 1
}
totalDirtyLineCount += metric.dirtyLineCount
if metric.restoredScrollPosition {
scrollRestorationCount += 1
}
totalRenderDurationMilliseconds += metric.durationMilliseconds
lastRenderDurationMilliseconds = metric.durationMilliseconds
lastRenderCharacterCount = metric.characterCount
lastRenderLineCount = metric.lineCount
lastDirtyLineCount = metric.dirtyLineCount
lastRenderReason = metric.reason
}
}
#if DEBUG
public enum EditorDiagnostics {
public static var isRenderLoggingEnabled = false
public static func logRenderPass(_ metric: EditorRenderPassMetric) {
guard isRenderLoggingEnabled else { return }
print(
"SaplingEditor render reason=\(metric.reason.rawValue) "
+ "full=\(metric.isFullRender) "
+ "dirtyLines=\(metric.dirtyLineCount)/\(metric.lineCount) "
+ "chars=\(metric.characterCount) "
+ "activeLine=\(metric.activeLineIndex) "
+ "durationMs=\(String(format: "%.3f", metric.durationMilliseconds)) "
+ "scrollRestored=\(metric.restoredScrollPosition)"
)
}
}
#endif

View file

@ -56,6 +56,9 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
instrumentation.recordRenderPass(metric)
#if DEBUG
EditorDiagnostics.logRenderPass(metric)
#endif
}
public func save() throws {
@ -213,6 +216,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
context.coordinator.performProgrammaticUpdate {
textView.string = text
}
context.coordinator.invalidateStylingCache()
}
let selectedRange = selection.range
@ -256,16 +260,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
guard shouldRestyle(textView.string) else { return }
let invalidationPlan = invalidationPlan(for: textView.string)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange()
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
let reason = renderReason(for: textView.string)
let start = Date()
var lineCount = 0
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreVisibleOrigin = false
performProgrammaticUpdate {
lineCount = MarkdownTextStyler.apply(
stylingResult = MarkdownTextStyler.apply(
to: textStorage,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
@ -277,17 +283,20 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
restoreVisibleOrigin(visibleOrigin, in: textView)
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
}
lastStyledText = textView.string
lastStyledActiveLineIndex = parent.activeLineIndex
parent.onRenderPass(EditorRenderPassMetric(
reason: reason,
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.string.utf16.count,
lineCount: lineCount,
activeLineIndex: parent.activeLineIndex
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: parent.activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreVisibleOrigin
))
}
@ -325,31 +334,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
}
private func renderReason(for text: String) -> EditorRenderReason {
if lastStyledText == nil {
return .initial
}
if lastStyledText != text {
return .sourceChange
}
if lastStyledActiveLineIndex != parent.activeLineIndex {
return .activeLineChange
}
return .viewUpdate
}
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) {
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
guard let origin,
let scrollView = textView.enclosingScrollView
else { return }
else { return false }
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
@ -359,6 +365,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
)
scrollView.contentView.scroll(to: clampedOrigin)
scrollView.reflectScrolledClipView(scrollView.contentView)
return true
}
}
}
@ -422,6 +429,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
context.coordinator.performProgrammaticUpdate {
textView.text = text
}
context.coordinator.invalidateStylingCache()
}
context.coordinator.applyHybridAttributes(to: textView)
context.coordinator.requestInitialFocus(for: textView)
@ -454,16 +462,18 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
}
func applyHybridAttributes(to textView: UITextView) {
guard shouldRestyle(textView.text) else { return }
let invalidationPlan = invalidationPlan(for: textView.text)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange
let contentOffset = textView.contentOffset
let reason = renderReason(for: textView.text)
let start = Date()
var lineCount = 0
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreContentOffset = false
performProgrammaticUpdate {
lineCount = MarkdownTextStyler.apply(
stylingResult = MarkdownTextStyler.apply(
to: textView.textStorage,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
@ -476,16 +486,20 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
textView.selectedRange = selectedRange
}
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
didRestoreContentOffset = true
}
lastStyledText = textView.text
lastStyledActiveLineIndex = parent.activeLineIndex
parent.onRenderPass(EditorRenderPassMetric(
reason: reason,
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.text.utf16.count,
lineCount: lineCount,
activeLineIndex: parent.activeLineIndex
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: parent.activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreContentOffset
))
}
@ -511,25 +525,22 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
}
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func shouldRestyle(_ text: String) -> Bool {
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
}
private func renderReason(for text: String) -> EditorRenderReason {
if lastStyledText == nil {
return .initial
}
if lastStyledText != text {
return .sourceChange
}
if lastStyledActiveLineIndex != parent.activeLineIndex {
return .activeLineChange
}
return .viewUpdate
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
}
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
@ -541,6 +552,13 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
}
#endif
private struct MarkdownTextStylingResult {
var totalLineCount: Int
var styledLineCount: Int
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
}
private enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
@ -551,23 +569,37 @@ private enum MarkdownTextStyler {
@discardableResult
static func apply(
to textStorage: NSTextStorage,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) -> Int {
) -> MarkdownTextStylingResult {
let source = textStorage.string as NSString
let fullRange = NSRange(location: 0, length: source.length)
let lines = EditorActiveLineTracker.lines(from: source as String, activeLineIndex: activeLineIndex)
guard fullRange.length > 0 else { return lines.count }
guard fullRange.length > 0 else {
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: lines.count)
}
textStorage.beginEditing()
if invalidationPlan.isFullRender {
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
}
let renderer = HybridMarkdownLineRenderer()
let dirtyLineIndexes = Set(invalidationPlan.dirtyLineIndexes)
var styledLineCount = 0
for line in lines {
guard invalidationPlan.isFullRender || dirtyLineIndexes.contains(line.index) else {
continue
}
resetAttributes(in: textStorage, line: line, textColor: textColor)
styledLineCount += 1
if line.index == activeLineIndex {
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
@ -586,7 +618,16 @@ private enum MarkdownTextStyler {
}
textStorage.endEditing()
return lines.count
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: styledLineCount)
}
private static func resetAttributes(
in textStorage: NSTextStorage,
line: EditorLine,
textColor: PlatformColor
) {
guard line.range.length > 0 else { return }
textStorage.setAttributes(baseAttributes(textColor: textColor), range: line.range)
}
private static func styleRenderedLine(

View file

@ -0,0 +1,100 @@
import XCTest
@testable import SaplingEditor
final class EditorCursorRegressionTests: XCTestCase {
func testCursorMovementUpdatesActiveLineWithoutChangingSelectionLength() {
let source = "Alpha\nBeta\nGamma"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: location(of: "Beta", in: source), length: 0))
XCTAssertEqual(state.selection, EditorSelection(location: 6, length: 0))
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.activeColumnNumber, 1)
}
func testSelectionChangeWithinLinePreservesActiveLine() {
let source = "Alpha\nBeta\nGamma"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: location(of: "eta", in: source), length: 2))
XCTAssertEqual(state.selection.length, 2)
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.activeColumnNumber, 2)
}
func testMultiLineSelectionUsesSelectionAnchorForActiveLine() {
let source = "Alpha\nBeta\nGamma"
var state = EditorState(document: document(source: source))
let anchor = location(of: "Beta", in: source)
let end = location(of: "Gamma", in: source) + "Gamma".utf16.count
state.updateSelection(EditorSelection(location: anchor, length: end - anchor))
XCTAssertEqual(state.selection.location, anchor)
XCTAssertEqual(state.selection.length, 10)
XCTAssertEqual(state.activeLineIndex, 1)
}
func testInsertionKeepsCursorAndActiveLineOnEditedLine() {
let source = "Alpha\nBeta\nGamma"
let updated = "Alpha\nBeta updated\nGamma"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: location(of: "Beta", in: source) + 4, length: 0))
state.updateSource(updated)
state.updateSelection(EditorSelection(location: location(of: "updated", in: updated) + 7, length: 0))
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.activeColumnNumber, 13)
}
func testDeletionClampsSelectionAndActiveLine() {
let source = "Alpha\nBeta\nGamma"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: source.utf16.count, length: 0))
state.updateSource("Alpha")
XCTAssertEqual(state.selection.location, 5)
XCTAssertEqual(state.selection.length, 0)
XCTAssertEqual(state.activeLineIndex, 0)
}
func testLineBreakMovesActiveLineAfterNativeSelectionUpdate() {
let source = "Alpha Beta"
let updated = "Alpha\n Beta"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: 5, length: 0))
state.updateSource(updated)
state.updateSelection(EditorSelection(location: 6, length: 0))
XCTAssertEqual(state.activeLineIndex, 1)
XCTAssertEqual(state.activeColumnNumber, 1)
}
func testActiveLineTransitionsAcrossDocument() {
let source = "One\nTwo\nThree\nFour"
var state = EditorState(document: document(source: source))
state.updateSelection(EditorSelection(location: location(of: "Four", in: source), length: 0))
XCTAssertEqual(state.activeLineIndex, 3)
state.updateSelection(EditorSelection(location: location(of: "One", in: source), length: 0))
XCTAssertEqual(state.activeLineIndex, 0)
}
private func document(source: String) -> EditorDocument {
EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorCursorRegressionTests.md"),
title: "EditorCursorRegressionTests",
source: source
)
}
private func location(of needle: String, in source: String) -> Int {
(source as NSString).range(of: needle).location
}
}

View file

@ -0,0 +1,81 @@
import XCTest
@testable import SaplingEditor
final class EditorDirtyLineInvalidationTests: XCTestCase {
func testInitialRenderTouchesEveryLine() {
let source = "One\nTwo\nThree"
let plan = EditorDirtyLineInvalidator.plan(
previousText: nil,
currentText: source,
previousActiveLineIndex: nil,
currentActiveLineIndex: 0
)
XCTAssertEqual(plan.reason, .initial)
XCTAssertTrue(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [0, 1, 2])
}
func testActiveLineChangeTouchesOnlyPreviousAndCurrentActiveLines() {
let source = "One\nTwo\nThree\nFour"
let plan = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: 1,
currentActiveLineIndex: 3
)
XCTAssertEqual(plan.reason, .activeLineChange)
XCTAssertFalse(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [1, 3])
}
func testSingleLineEditTouchesChangedLineAndNeighbors() {
let previous = "One\nTwo\nThree\nFour\nFive"
let current = "One\nTwo\nThree updated\nFour\nFive"
let plan = EditorDirtyLineInvalidator.plan(
previousText: previous,
currentText: current,
previousActiveLineIndex: 2,
currentActiveLineIndex: 2
)
XCTAssertEqual(plan.reason, .sourceChange)
XCTAssertFalse(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [1, 2, 3])
}
func testLineBreakInsertionTouchesSplitLineAndNeighbors() {
let previous = "One\nTwo three\nFour"
let current = "One\nTwo\nthree\nFour"
let plan = EditorDirtyLineInvalidator.plan(
previousText: previous,
currentText: current,
previousActiveLineIndex: 1,
currentActiveLineIndex: 2
)
XCTAssertEqual(plan.reason, .sourceChange)
XCTAssertFalse(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [0, 1, 2])
}
func testUnchangedViewUpdateDoesNotRequireStyling() {
let source = "One\nTwo"
let plan = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: 1,
currentActiveLineIndex: 1
)
XCTAssertEqual(plan.reason, .viewUpdate)
XCTAssertFalse(plan.requiresStyling)
XCTAssertTrue(plan.dirtyLineIndexes.isEmpty)
}
}

View file

@ -0,0 +1,106 @@
import XCTest
@testable import SaplingEditor
final class EditorLargeDocumentValidationTests: XCTestCase {
func testLargeDocumentOpenAndDirtyRenderPlanning() {
for lineCount in [1_000, 5_000, 10_000] {
let source = Self.prototypeDocument(lineCount: lineCount)
let activeLineIndex = lineCount / 2
let openMeasurement = elapsedTime {
_ = EditorState(
document: document(source: source, lineCount: lineCount),
activeLineIndex: activeLineIndex
)
}
let updatedSource = Self.replacingLine(
activeLineIndex,
in: source,
with: "Line \(activeLineIndex + 1) edited with **bold** and `inline code`."
)
let updateMeasurement = elapsedTime {
var state = EditorState(
document: document(source: source, lineCount: lineCount),
activeLineIndex: activeLineIndex
)
state.updateSource(updatedSource)
_ = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: updatedSource,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: activeLineIndex
)
}
let plan = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: updatedSource,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: activeLineIndex
)
let dirtyRenderMeasurement = elapsedTime {
let lines = EditorActiveLineTracker.lines(from: updatedSource, activeLineIndex: activeLineIndex)
let dirty = Set(plan.dirtyLineIndexes)
let renderer = HybridMarkdownLineRenderer()
_ = lines
.filter { dirty.contains($0.index) }
.map(renderer.renderPlan(for:))
}
XCTAssertEqual(EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex).count, lineCount)
XCTAssertFalse(plan.isFullRender)
XCTAssertLessThanOrEqual(plan.dirtyLineCount, 3)
XCTAssertLessThan(openMeasurement, 0.25, "Opening \(lineCount) generated lines should remain interactive.")
XCTAssertLessThan(updateMeasurement, 0.50, "State update for \(lineCount) lines should remain bounded.")
XCTAssertLessThan(dirtyRenderMeasurement, 0.05, "Dirty render planning should not scale with the full document.")
}
}
func testLargeDocumentActiveLineNavigationDoesNotScheduleFullRender() {
let source = Self.prototypeDocument(lineCount: 10_000)
let plan = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: 250,
currentActiveLineIndex: 9_750
)
XCTAssertEqual(plan.reason, .activeLineChange)
XCTAssertFalse(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [250, 9_750])
}
private func document(source: String, lineCount: Int) -> EditorDocument {
EditorDocument(
url: URL(fileURLWithPath: "/tmp/EditorLargeDocument-\(lineCount).md"),
title: "EditorLargeDocument-\(lineCount)",
source: source
)
}
private func elapsedTime(_ operation: () -> Void) -> TimeInterval {
let start = Date()
operation()
return Date().timeIntervalSince(start)
}
private static func prototypeDocument(lineCount: Int) -> String {
(1...lineCount).map { index in
if index.isMultiple(of: 25) {
return "## Section \(index / 25)"
}
if index.isMultiple(of: 5) {
return "Line \(index) uses **bold** context and `inline code` for scanning."
}
return "Line \(index) is a realistic paragraph with *light emphasis* and enough text to wrap naturally."
}
.joined(separator: "\n")
}
private static func replacingLine(_ lineIndex: Int, in source: String, with replacement: String) -> String {
var lines = source.components(separatedBy: "\n")
lines[lineIndex] = replacement
return lines.joined(separator: "\n")
}
}

View file

@ -0,0 +1,76 @@
import XCTest
@testable import SaplingEditor
final class EditorScrollStabilityTests: XCTestCase {
func testRenderMetricsCountScrollRestorationDuringDirtyEdit() {
var instrumentation = EditorInstrumentationSnapshot()
instrumentation.recordRenderPass(EditorRenderPassMetric(
reason: .sourceChange,
durationMilliseconds: 0.4,
characterCount: 1_200,
lineCount: 80,
dirtyLineCount: 3,
activeLineIndex: 40,
isFullRender: false,
restoredScrollPosition: true
))
XCTAssertEqual(instrumentation.renderPassCount, 1)
XCTAssertEqual(instrumentation.fullRenderCount, 0)
XCTAssertEqual(instrumentation.totalDirtyLineCount, 3)
XCTAssertEqual(instrumentation.lastDirtyLineCount, 3)
XCTAssertEqual(instrumentation.scrollRestorationCount, 1)
}
func testRapidNavigationSchedulesOnlyOldAndNewActiveLines() {
let source = (1...500).map { "Line \($0)" }.joined(separator: "\n")
let firstJump = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: 10,
currentActiveLineIndex: 250
)
let secondJump = EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
previousActiveLineIndex: 250,
currentActiveLineIndex: 490
)
XCTAssertEqual(firstJump.dirtyLineIndexes, [10, 250])
XCTAssertEqual(secondJump.dirtyLineIndexes, [250, 490])
XCTAssertFalse(firstJump.isFullRender)
XCTAssertFalse(secondJump.isFullRender)
}
func testEditingWhileScrolledDoesNotRequireFullRender() {
let previous = (1...200).map { "Line \($0)" }.joined(separator: "\n")
var lines = previous.components(separatedBy: "\n")
lines[120] = "Line 121 edited while viewport is away from the top"
let current = lines.joined(separator: "\n")
let plan = EditorDirtyLineInvalidator.plan(
previousText: previous,
currentText: current,
previousActiveLineIndex: 120,
currentActiveLineIndex: 120
)
var instrumentation = EditorInstrumentationSnapshot()
instrumentation.recordRenderPass(EditorRenderPassMetric(
reason: plan.reason,
durationMilliseconds: 0.5,
characterCount: current.utf16.count,
lineCount: 200,
dirtyLineCount: plan.dirtyLineCount,
activeLineIndex: 120,
isFullRender: plan.isFullRender,
restoredScrollPosition: true
))
XCTAssertEqual(plan.reason, .sourceChange)
XCTAssertFalse(plan.isFullRender)
XCTAssertEqual(plan.dirtyLineIndexes, [119, 120, 121])
XCTAssertEqual(instrumentation.scrollRestorationCount, 1)
}
}