perf(editor): implement dirty-line invalidation
This commit is contained in:
parent
6b1d2f8b27
commit
6aefffef8d
7 changed files with 649 additions and 52 deletions
140
Sources/SaplingEditor/EditorDirtyLineInvalidation.swift
Normal file
140
Sources/SaplingEditor/EditorDirtyLineInvalidation.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,53 +12,77 @@ public struct EditorRenderPassMetric: Hashable, Sendable {
|
||||||
public var durationMilliseconds: Double
|
public var durationMilliseconds: Double
|
||||||
public var characterCount: Int
|
public var characterCount: Int
|
||||||
public var lineCount: Int
|
public var lineCount: Int
|
||||||
|
public var dirtyLineCount: Int
|
||||||
public var activeLineIndex: Int
|
public var activeLineIndex: Int
|
||||||
|
public var isFullRender: Bool
|
||||||
|
public var restoredScrollPosition: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
reason: EditorRenderReason,
|
reason: EditorRenderReason,
|
||||||
durationMilliseconds: Double,
|
durationMilliseconds: Double,
|
||||||
characterCount: Int,
|
characterCount: Int,
|
||||||
lineCount: Int,
|
lineCount: Int,
|
||||||
activeLineIndex: Int
|
dirtyLineCount: Int = 0,
|
||||||
|
activeLineIndex: Int,
|
||||||
|
isFullRender: Bool = false,
|
||||||
|
restoredScrollPosition: Bool = false
|
||||||
) {
|
) {
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
self.durationMilliseconds = durationMilliseconds
|
self.durationMilliseconds = durationMilliseconds
|
||||||
self.characterCount = characterCount
|
self.characterCount = characterCount
|
||||||
self.lineCount = lineCount
|
self.lineCount = lineCount
|
||||||
|
self.dirtyLineCount = dirtyLineCount
|
||||||
self.activeLineIndex = activeLineIndex
|
self.activeLineIndex = activeLineIndex
|
||||||
|
self.isFullRender = isFullRender
|
||||||
|
self.restoredScrollPosition = restoredScrollPosition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct EditorInstrumentationSnapshot: Hashable, Sendable {
|
public struct EditorInstrumentationSnapshot: Hashable, Sendable {
|
||||||
public var sourceChangeCount: Int
|
public var sourceChangeCount: Int
|
||||||
public var selectionChangeCount: Int
|
public var selectionChangeCount: Int
|
||||||
|
public var selectionUpdateCount: Int
|
||||||
public var activeLineChangeCount: Int
|
public var activeLineChangeCount: Int
|
||||||
public var renderPassCount: Int
|
public var renderPassCount: Int
|
||||||
|
public var fullRenderCount: Int
|
||||||
|
public var totalDirtyLineCount: Int
|
||||||
|
public var scrollRestorationCount: Int
|
||||||
public var totalRenderDurationMilliseconds: Double
|
public var totalRenderDurationMilliseconds: Double
|
||||||
public var lastRenderDurationMilliseconds: Double
|
public var lastRenderDurationMilliseconds: Double
|
||||||
public var lastRenderCharacterCount: Int
|
public var lastRenderCharacterCount: Int
|
||||||
public var lastRenderLineCount: Int
|
public var lastRenderLineCount: Int
|
||||||
|
public var lastDirtyLineCount: Int
|
||||||
public var lastRenderReason: EditorRenderReason?
|
public var lastRenderReason: EditorRenderReason?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
sourceChangeCount: Int = 0,
|
sourceChangeCount: Int = 0,
|
||||||
selectionChangeCount: Int = 0,
|
selectionChangeCount: Int = 0,
|
||||||
|
selectionUpdateCount: Int = 0,
|
||||||
activeLineChangeCount: Int = 0,
|
activeLineChangeCount: Int = 0,
|
||||||
renderPassCount: Int = 0,
|
renderPassCount: Int = 0,
|
||||||
|
fullRenderCount: Int = 0,
|
||||||
|
totalDirtyLineCount: Int = 0,
|
||||||
|
scrollRestorationCount: Int = 0,
|
||||||
totalRenderDurationMilliseconds: Double = 0,
|
totalRenderDurationMilliseconds: Double = 0,
|
||||||
lastRenderDurationMilliseconds: Double = 0,
|
lastRenderDurationMilliseconds: Double = 0,
|
||||||
lastRenderCharacterCount: Int = 0,
|
lastRenderCharacterCount: Int = 0,
|
||||||
lastRenderLineCount: Int = 0,
|
lastRenderLineCount: Int = 0,
|
||||||
|
lastDirtyLineCount: Int = 0,
|
||||||
lastRenderReason: EditorRenderReason? = nil
|
lastRenderReason: EditorRenderReason? = nil
|
||||||
) {
|
) {
|
||||||
self.sourceChangeCount = sourceChangeCount
|
self.sourceChangeCount = sourceChangeCount
|
||||||
self.selectionChangeCount = selectionChangeCount
|
self.selectionChangeCount = selectionChangeCount
|
||||||
|
self.selectionUpdateCount = selectionUpdateCount
|
||||||
self.activeLineChangeCount = activeLineChangeCount
|
self.activeLineChangeCount = activeLineChangeCount
|
||||||
self.renderPassCount = renderPassCount
|
self.renderPassCount = renderPassCount
|
||||||
|
self.fullRenderCount = fullRenderCount
|
||||||
|
self.totalDirtyLineCount = totalDirtyLineCount
|
||||||
|
self.scrollRestorationCount = scrollRestorationCount
|
||||||
self.totalRenderDurationMilliseconds = totalRenderDurationMilliseconds
|
self.totalRenderDurationMilliseconds = totalRenderDurationMilliseconds
|
||||||
self.lastRenderDurationMilliseconds = lastRenderDurationMilliseconds
|
self.lastRenderDurationMilliseconds = lastRenderDurationMilliseconds
|
||||||
self.lastRenderCharacterCount = lastRenderCharacterCount
|
self.lastRenderCharacterCount = lastRenderCharacterCount
|
||||||
self.lastRenderLineCount = lastRenderLineCount
|
self.lastRenderLineCount = lastRenderLineCount
|
||||||
|
self.lastDirtyLineCount = lastDirtyLineCount
|
||||||
self.lastRenderReason = lastRenderReason
|
self.lastRenderReason = lastRenderReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +97,7 @@ public struct EditorInstrumentationSnapshot: Hashable, Sendable {
|
||||||
|
|
||||||
public mutating func recordSelectionChange() {
|
public mutating func recordSelectionChange() {
|
||||||
selectionChangeCount += 1
|
selectionChangeCount += 1
|
||||||
|
selectionUpdateCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func recordActiveLineChange() {
|
public mutating func recordActiveLineChange() {
|
||||||
|
|
@ -81,10 +106,38 @@ public struct EditorInstrumentationSnapshot: Hashable, Sendable {
|
||||||
|
|
||||||
public mutating func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
public mutating func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
||||||
renderPassCount += 1
|
renderPassCount += 1
|
||||||
|
if metric.isFullRender {
|
||||||
|
fullRenderCount += 1
|
||||||
|
}
|
||||||
|
totalDirtyLineCount += metric.dirtyLineCount
|
||||||
|
if metric.restoredScrollPosition {
|
||||||
|
scrollRestorationCount += 1
|
||||||
|
}
|
||||||
totalRenderDurationMilliseconds += metric.durationMilliseconds
|
totalRenderDurationMilliseconds += metric.durationMilliseconds
|
||||||
lastRenderDurationMilliseconds = metric.durationMilliseconds
|
lastRenderDurationMilliseconds = metric.durationMilliseconds
|
||||||
lastRenderCharacterCount = metric.characterCount
|
lastRenderCharacterCount = metric.characterCount
|
||||||
lastRenderLineCount = metric.lineCount
|
lastRenderLineCount = metric.lineCount
|
||||||
|
lastDirtyLineCount = metric.dirtyLineCount
|
||||||
lastRenderReason = metric.reason
|
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
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,9 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
|
||||||
|
|
||||||
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
||||||
instrumentation.recordRenderPass(metric)
|
instrumentation.recordRenderPass(metric)
|
||||||
|
#if DEBUG
|
||||||
|
EditorDiagnostics.logRenderPass(metric)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public func save() throws {
|
public func save() throws {
|
||||||
|
|
@ -213,6 +216,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
context.coordinator.performProgrammaticUpdate {
|
context.coordinator.performProgrammaticUpdate {
|
||||||
textView.string = text
|
textView.string = text
|
||||||
}
|
}
|
||||||
|
context.coordinator.invalidateStylingCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedRange = selection.range
|
let selectedRange = selection.range
|
||||||
|
|
@ -256,16 +260,18 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: NSTextView) {
|
func applyHybridAttributes(to textView: NSTextView) {
|
||||||
guard let textStorage = textView.textStorage else { return }
|
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 selectedRange = textView.selectedRange()
|
||||||
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
||||||
let reason = renderReason(for: textView.string)
|
|
||||||
let start = Date()
|
let start = Date()
|
||||||
var lineCount = 0
|
var stylingResult = MarkdownTextStylingResult.empty
|
||||||
|
var didRestoreVisibleOrigin = false
|
||||||
performProgrammaticUpdate {
|
performProgrammaticUpdate {
|
||||||
lineCount = MarkdownTextStyler.apply(
|
stylingResult = MarkdownTextStyler.apply(
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: parent.activeLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||||
|
|
@ -277,17 +283,20 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
selectedRange.location <= textView.string.utf16.count {
|
selectedRange.location <= textView.string.utf16.count {
|
||||||
textView.setSelectedRange(selectedRange)
|
textView.setSelectedRange(selectedRange)
|
||||||
}
|
}
|
||||||
restoreVisibleOrigin(visibleOrigin, in: textView)
|
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastStyledText = textView.string
|
lastStyledText = textView.string
|
||||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||||
parent.onRenderPass(EditorRenderPassMetric(
|
parent.onRenderPass(EditorRenderPassMetric(
|
||||||
reason: reason,
|
reason: invalidationPlan.reason,
|
||||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||||
characterCount: textView.string.utf16.count,
|
characterCount: textView.string.utf16.count,
|
||||||
lineCount: lineCount,
|
lineCount: stylingResult.totalLineCount,
|
||||||
activeLineIndex: parent.activeLineIndex
|
dirtyLineCount: stylingResult.styledLineCount,
|
||||||
|
activeLineIndex: parent.activeLineIndex,
|
||||||
|
isFullRender: invalidationPlan.isFullRender,
|
||||||
|
restoredScrollPosition: didRestoreVisibleOrigin
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,31 +334,28 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
updates()
|
updates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func invalidateStylingCache() {
|
||||||
|
lastStyledText = nil
|
||||||
|
lastStyledActiveLineIndex = nil
|
||||||
|
}
|
||||||
|
|
||||||
private var isPerformingProgrammaticUpdate: Bool {
|
private var isPerformingProgrammaticUpdate: Bool {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldRestyle(_ text: String) -> Bool {
|
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||||
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
|
EditorDirtyLineInvalidator.plan(
|
||||||
|
previousText: lastStyledText,
|
||||||
|
currentText: text,
|
||||||
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||||
|
currentActiveLineIndex: parent.activeLineIndex
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func renderReason(for text: String) -> EditorRenderReason {
|
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
|
||||||
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) {
|
|
||||||
guard let origin,
|
guard let origin,
|
||||||
let scrollView = textView.enclosingScrollView
|
let scrollView = textView.enclosingScrollView
|
||||||
else { return }
|
else { return false }
|
||||||
|
|
||||||
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
||||||
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
|
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.contentView.scroll(to: clampedOrigin)
|
||||||
scrollView.reflectScrolledClipView(scrollView.contentView)
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,6 +429,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
context.coordinator.performProgrammaticUpdate {
|
context.coordinator.performProgrammaticUpdate {
|
||||||
textView.text = text
|
textView.text = text
|
||||||
}
|
}
|
||||||
|
context.coordinator.invalidateStylingCache()
|
||||||
}
|
}
|
||||||
context.coordinator.applyHybridAttributes(to: textView)
|
context.coordinator.applyHybridAttributes(to: textView)
|
||||||
context.coordinator.requestInitialFocus(for: textView)
|
context.coordinator.requestInitialFocus(for: textView)
|
||||||
|
|
@ -454,16 +462,18 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: UITextView) {
|
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 selectedRange = textView.selectedRange
|
||||||
let contentOffset = textView.contentOffset
|
let contentOffset = textView.contentOffset
|
||||||
let reason = renderReason(for: textView.text)
|
|
||||||
let start = Date()
|
let start = Date()
|
||||||
var lineCount = 0
|
var stylingResult = MarkdownTextStylingResult.empty
|
||||||
|
var didRestoreContentOffset = false
|
||||||
performProgrammaticUpdate {
|
performProgrammaticUpdate {
|
||||||
lineCount = MarkdownTextStyler.apply(
|
stylingResult = MarkdownTextStyler.apply(
|
||||||
to: textView.textStorage,
|
to: textView.textStorage,
|
||||||
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: parent.activeLineIndex,
|
||||||
backgroundColor: .systemBackground,
|
backgroundColor: .systemBackground,
|
||||||
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
||||||
|
|
@ -476,16 +486,20 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
textView.selectedRange = selectedRange
|
textView.selectedRange = selectedRange
|
||||||
}
|
}
|
||||||
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
|
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
|
||||||
|
didRestoreContentOffset = true
|
||||||
}
|
}
|
||||||
|
|
||||||
lastStyledText = textView.text
|
lastStyledText = textView.text
|
||||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||||
parent.onRenderPass(EditorRenderPassMetric(
|
parent.onRenderPass(EditorRenderPassMetric(
|
||||||
reason: reason,
|
reason: invalidationPlan.reason,
|
||||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||||
characterCount: textView.text.utf16.count,
|
characterCount: textView.text.utf16.count,
|
||||||
lineCount: lineCount,
|
lineCount: stylingResult.totalLineCount,
|
||||||
activeLineIndex: parent.activeLineIndex
|
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 {
|
private var isPerformingProgrammaticUpdate: Bool {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldRestyle(_ text: String) -> Bool {
|
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||||
lastStyledText != text || lastStyledActiveLineIndex != parent.activeLineIndex
|
EditorDirtyLineInvalidator.plan(
|
||||||
}
|
previousText: lastStyledText,
|
||||||
|
currentText: text,
|
||||||
private func renderReason(for text: String) -> EditorRenderReason {
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||||
if lastStyledText == nil {
|
currentActiveLineIndex: parent.activeLineIndex
|
||||||
return .initial
|
)
|
||||||
}
|
|
||||||
if lastStyledText != text {
|
|
||||||
return .sourceChange
|
|
||||||
}
|
|
||||||
if lastStyledActiveLineIndex != parent.activeLineIndex {
|
|
||||||
return .activeLineChange
|
|
||||||
}
|
|
||||||
return .viewUpdate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
|
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
|
||||||
|
|
@ -541,6 +552,13 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private struct MarkdownTextStylingResult {
|
||||||
|
var totalLineCount: Int
|
||||||
|
var styledLineCount: Int
|
||||||
|
|
||||||
|
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
|
||||||
|
}
|
||||||
|
|
||||||
private enum MarkdownTextStyler {
|
private enum MarkdownTextStyler {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
typealias PlatformColor = NSColor
|
typealias PlatformColor = NSColor
|
||||||
|
|
@ -551,23 +569,37 @@ private enum MarkdownTextStyler {
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func apply(
|
static func apply(
|
||||||
to textStorage: NSTextStorage,
|
to textStorage: NSTextStorage,
|
||||||
|
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
||||||
activeLineIndex: Int,
|
activeLineIndex: Int,
|
||||||
backgroundColor: PlatformColor,
|
backgroundColor: PlatformColor,
|
||||||
activeLineBackgroundColor: PlatformColor,
|
activeLineBackgroundColor: PlatformColor,
|
||||||
textColor: PlatformColor,
|
textColor: PlatformColor,
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor
|
accentColor: PlatformColor
|
||||||
) -> Int {
|
) -> MarkdownTextStylingResult {
|
||||||
let source = textStorage.string as NSString
|
let source = textStorage.string as NSString
|
||||||
let fullRange = NSRange(location: 0, length: source.length)
|
let fullRange = NSRange(location: 0, length: source.length)
|
||||||
let lines = EditorActiveLineTracker.lines(from: source as String, activeLineIndex: activeLineIndex)
|
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.beginEditing()
|
||||||
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
if invalidationPlan.isFullRender {
|
||||||
|
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
let renderer = HybridMarkdownLineRenderer()
|
let renderer = HybridMarkdownLineRenderer()
|
||||||
|
let dirtyLineIndexes = Set(invalidationPlan.dirtyLineIndexes)
|
||||||
|
var styledLineCount = 0
|
||||||
for line in lines {
|
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 {
|
if line.index == activeLineIndex {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.backgroundColor: activeLineBackgroundColor,
|
.backgroundColor: activeLineBackgroundColor,
|
||||||
|
|
@ -586,7 +618,16 @@ private enum MarkdownTextStyler {
|
||||||
}
|
}
|
||||||
|
|
||||||
textStorage.endEditing()
|
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(
|
private static func styleRenderedLine(
|
||||||
|
|
|
||||||
100
Tests/SaplingEditorTests/EditorCursorRegressionTests.swift
Normal file
100
Tests/SaplingEditorTests/EditorCursorRegressionTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
76
Tests/SaplingEditorTests/EditorScrollStabilityTests.swift
Normal file
76
Tests/SaplingEditorTests/EditorScrollStabilityTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue