From 6aefffef8dd4914a8ced74a87d2eb5c597f16fea Mon Sep 17 00:00:00 2001 From: Feror Date: Fri, 29 May 2026 20:57:03 +0200 Subject: [PATCH] perf(editor): implement dirty-line invalidation --- .../EditorDirtyLineInvalidation.swift | 140 +++++++++++++++++ .../SaplingEditor/EditorInstrumentation.swift | 55 ++++++- .../SaplingEditor/HybridMarkdownEditor.swift | 143 +++++++++++------- .../EditorCursorRegressionTests.swift | 100 ++++++++++++ .../EditorDirtyLineInvalidationTests.swift | 81 ++++++++++ .../EditorLargeDocumentValidationTests.swift | 106 +++++++++++++ .../EditorScrollStabilityTests.swift | 76 ++++++++++ 7 files changed, 649 insertions(+), 52 deletions(-) create mode 100644 Sources/SaplingEditor/EditorDirtyLineInvalidation.swift create mode 100644 Tests/SaplingEditorTests/EditorCursorRegressionTests.swift create mode 100644 Tests/SaplingEditorTests/EditorDirtyLineInvalidationTests.swift create mode 100644 Tests/SaplingEditorTests/EditorLargeDocumentValidationTests.swift create mode 100644 Tests/SaplingEditorTests/EditorScrollStabilityTests.swift diff --git a/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift new file mode 100644 index 0000000..8c416d7 --- /dev/null +++ b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift @@ -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() + 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.. 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 { + 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 + } +} diff --git a/Sources/SaplingEditor/EditorInstrumentation.swift b/Sources/SaplingEditor/EditorInstrumentation.swift index 2ca6db9..68736a4 100644 --- a/Sources/SaplingEditor/EditorInstrumentation.swift +++ b/Sources/SaplingEditor/EditorInstrumentation.swift @@ -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 diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index a52b4a3..96d9eef 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -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() - textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange) + 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( diff --git a/Tests/SaplingEditorTests/EditorCursorRegressionTests.swift b/Tests/SaplingEditorTests/EditorCursorRegressionTests.swift new file mode 100644 index 0000000..c604af4 --- /dev/null +++ b/Tests/SaplingEditorTests/EditorCursorRegressionTests.swift @@ -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 + } +} diff --git a/Tests/SaplingEditorTests/EditorDirtyLineInvalidationTests.swift b/Tests/SaplingEditorTests/EditorDirtyLineInvalidationTests.swift new file mode 100644 index 0000000..e306a52 --- /dev/null +++ b/Tests/SaplingEditorTests/EditorDirtyLineInvalidationTests.swift @@ -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) + } +} diff --git a/Tests/SaplingEditorTests/EditorLargeDocumentValidationTests.swift b/Tests/SaplingEditorTests/EditorLargeDocumentValidationTests.swift new file mode 100644 index 0000000..c30a6a5 --- /dev/null +++ b/Tests/SaplingEditorTests/EditorLargeDocumentValidationTests.swift @@ -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") + } +} diff --git a/Tests/SaplingEditorTests/EditorScrollStabilityTests.swift b/Tests/SaplingEditorTests/EditorScrollStabilityTests.swift new file mode 100644 index 0000000..a64bc3f --- /dev/null +++ b/Tests/SaplingEditorTests/EditorScrollStabilityTests.swift @@ -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) + } +}