perf(editor): introduce incremental line index

This commit is contained in:
Feror 2026-05-30 19:24:48 +02:00
parent 6426dc494a
commit a29844dfbc
7 changed files with 477 additions and 59 deletions

View file

@ -38,6 +38,34 @@ public struct DocumentLineBoundary: Hashable, Sendable {
}
}
public struct DocumentLineIndexEdit: Hashable, Sendable {
public var range: NSRange
public var replacement: String
public init(range: NSRange, replacement: String) {
self.range = range
self.replacement = replacement
}
public var replacementUTF16Length: Int {
(replacement as NSString).length
}
public var insertedRangeInEditedDocument: NSRange {
NSRange(location: range.location, length: replacementUTF16Length)
}
}
public struct DocumentLineIndexEditResult: Hashable, Sendable {
public var replacedLineRange: Range<Int>
public var insertedLineRange: Range<Int>
public var locationDelta: Int
public var insertedLineIndexes: [Int] {
Array(insertedLineRange)
}
}
public struct DocumentLineIndex: Hashable, Sendable {
public var source: String
public var boundaries: [DocumentLineBoundary]
@ -47,59 +75,185 @@ public struct DocumentLineIndex: Hashable, Sendable {
self.boundaries = Self.scanBoundaries(in: source)
}
public var lineCount: Int {
boundaries.count
}
public func boundary(at index: Int) -> DocumentLineBoundary? {
guard boundaries.indices.contains(index) else { return nil }
return boundaries[index]
}
public func lineIndex(containing location: Int) -> Int {
guard !boundaries.isEmpty else { return 0 }
let clampedLocation = max(0, min(location, source.utf16.count))
for boundary in boundaries.dropLast() {
if clampedLocation < boundary.nextLineLocation {
return boundary.index
var lowerBound = 0
var upperBound = boundaries.count - 1
while lowerBound < upperBound {
let midpoint = (lowerBound + upperBound) / 2
if clampedLocation < boundaries[midpoint].nextLineLocation {
upperBound = midpoint
} else {
lowerBound = midpoint + 1
}
}
return boundaries[boundaries.count - 1].index
return boundaries[lowerBound].index
}
public func lineStartOffset(forLine index: Int) -> Int? {
boundary(at: index)?.contentRange.location
}
public func lineContentRange(forLine index: Int) -> NSRange? {
boundary(at: index)?.contentRange
}
public func lineIndexesAffected(by changedRange: NSRange, includingNeighborLines: Bool = true) -> [Int] {
guard !boundaries.isEmpty 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 = lineIndex(containing: startLocation)
let endLine = lineIndex(containing: endLocation)
let neighborOffset = includingNeighborLines ? 1 : 0
let lowerBound = max(0, min(startLine, endLine) - neighborOffset)
let upperBound = min(boundaries.count - 1, max(startLine, endLine) + neighborOffset)
return Array(lowerBound...upperBound)
}
public func editorLine(at index: Int, activeLineIndex: Int) -> EditorLine? {
guard let boundary = boundary(at: index) else { return nil }
return editorLine(for: boundary, activeLineIndex: activeLineIndex)
}
public func editorLines(for indexes: some Sequence<Int>, activeLineIndex: Int) -> [EditorLine] {
indexes.compactMap { editorLine(at: $0, activeLineIndex: activeLineIndex) }
}
public func editorLines(activeLineIndex: Int) -> [EditorLine] {
boundaries.map { boundary in
editorLine(for: boundary, activeLineIndex: activeLineIndex)
}
}
@discardableResult
public mutating func replaceCharacters(in range: NSRange, with replacement: String) -> DocumentLineIndexEditResult {
replace(DocumentLineIndexEdit(range: range, replacement: replacement))
}
@discardableResult
public mutating func replace(_ edit: DocumentLineIndexEdit) -> DocumentLineIndexEditResult {
let oldSource = source as NSString
let oldLength = oldSource.length
let range = NSRange(
location: max(0, min(edit.range.location, oldLength)),
length: max(0, min(edit.range.length, oldLength - max(0, min(edit.range.location, oldLength))))
)
let replacement = edit.replacement
let replacementLength = (replacement as NSString).length
let locationDelta = replacementLength - range.length
let lowerProbe = max(0, range.location - 1)
let upperProbe = min(oldLength, range.upperBound + 1)
let lowerLineIndex = max(0, lineIndex(containing: lowerProbe) - 1)
let upperLineIndex = min(boundaries.count - 1, lineIndex(containing: upperProbe) + 1)
let replacedLineRange = lowerLineIndex..<(upperLineIndex + 1)
let scanStart = boundaries[lowerLineIndex].contentRange.location
let oldScanEnd = boundaries[upperLineIndex].nextLineLocation
source = oldSource.replacingCharacters(in: range, with: replacement)
let newSource = source as NSString
let newScanEnd = max(scanStart, min(newSource.length, oldScanEnd + locationDelta))
let scannedBoundaries = Self.scanBoundaries(
in: newSource,
range: NSRange(location: scanStart, length: newScanEnd - scanStart),
startingIndex: lowerLineIndex,
includeTrailingBlankLine: newScanEnd == newSource.length
)
boundaries.replaceSubrange(replacedLineRange, with: scannedBoundaries)
let insertedLineRange = lowerLineIndex..<(lowerLineIndex + scannedBoundaries.count)
let indexDelta = scannedBoundaries.count - replacedLineRange.count
let followingStart = insertedLineRange.upperBound
if followingStart < boundaries.count {
for boundaryIndex in followingStart..<boundaries.count {
boundaries[boundaryIndex].index += indexDelta
boundaries[boundaryIndex].contentRange.location += locationDelta
}
}
return DocumentLineIndexEditResult(
replacedLineRange: replacedLineRange,
insertedLineRange: insertedLineRange,
locationDelta: locationDelta
)
}
private func editorLine(for boundary: DocumentLineBoundary, activeLineIndex: Int) -> EditorLine {
let nsSource = source as NSString
return boundaries.map { boundary in
EditorLine(
return EditorLine(
index: boundary.index,
source: nsSource.substring(with: boundary.contentRange),
range: boundary.contentRange,
mode: boundary.index == activeLineIndex ? .source : .rendered
)
}
}
private static func scanBoundaries(in source: String) -> [DocumentLineBoundary] {
let nsSource = source as NSString
let sourceLength = nsSource.length
return scanBoundaries(
in: nsSource,
range: NSRange(location: 0, length: nsSource.length),
startingIndex: 0,
includeTrailingBlankLine: true
)
}
private static func scanBoundaries(
in source: NSString,
range: NSRange,
startingIndex: Int,
includeTrailingBlankLine: Bool
) -> [DocumentLineBoundary] {
let sourceLength = range.upperBound
guard sourceLength > 0 else {
return [
DocumentLineBoundary(
index: 0,
contentRange: NSRange(location: 0, length: 0),
index: startingIndex,
contentRange: NSRange(location: range.location, length: 0),
lineEnding: .none
)
]
}
var boundaries: [DocumentLineBoundary] = []
var lineStart = 0
var lineIndex = 0
var lineStart = range.location
var lineIndex = startingIndex
var endedWithLineEnding = false
while lineStart < sourceLength {
var cursor = lineStart
while cursor < sourceLength,
!isLineEndingStart(nsSource.character(at: cursor)) {
!isLineEndingStart(source.character(at: cursor)) {
cursor += 1
}
let contentRange = NSRange(location: lineStart, length: cursor - lineStart)
if cursor < sourceLength {
let lineEnding = lineEndingStrategy(at: cursor, in: nsSource)
let lineEnding = lineEndingStrategy(at: cursor, in: source, scanEnd: sourceLength)
boundaries.append(DocumentLineBoundary(
index: lineIndex,
contentRange: contentRange,
@ -121,7 +275,7 @@ public struct DocumentLineIndex: Hashable, Sendable {
lineIndex += 1
}
if endedWithLineEnding {
if endedWithLineEnding, includeTrailingBlankLine {
boundaries.append(DocumentLineBoundary(
index: lineIndex,
contentRange: NSRange(location: sourceLength, length: 0),
@ -132,11 +286,12 @@ public struct DocumentLineIndex: Hashable, Sendable {
return boundaries
}
private static func lineEndingStrategy(at location: Int, in source: NSString) -> LineEndingStrategy {
private static func lineEndingStrategy(at location: Int, in source: NSString, scanEnd: Int? = nil) -> LineEndingStrategy {
let character = source.character(at: location)
if character == carriageReturnUTF16 {
let nextLocation = location + 1
if nextLocation < source.length,
let upperBound = scanEnd ?? source.length
if nextLocation < upperBound,
source.character(at: nextLocation) == lineFeedUTF16 {
return .crlf
}

View file

@ -5,10 +5,18 @@ public enum EditorActiveLineTracker {
DocumentLineIndex(source: source).editorLines(activeLineIndex: activeLineIndex)
}
public static func lines(from lineIndex: DocumentLineIndex, activeLineIndex: Int) -> [EditorLine] {
lineIndex.editorLines(activeLineIndex: activeLineIndex)
}
public static func lineIndex(containing location: Int, in source: String) -> Int {
DocumentLineIndex(source: source).lineIndex(containing: location)
}
public static func lineIndex(containing location: Int, in lineIndex: DocumentLineIndex) -> Int {
lineIndex.lineIndex(containing: location)
}
public static func clampedSelection(_ selection: EditorSelection, in source: String) -> EditorSelection {
let sourceLength = source.utf16.count
let location = max(0, min(selection.location, sourceLength))

View file

@ -84,10 +84,18 @@ public struct EditorSelection: Hashable, Codable, Sendable {
public struct EditorState: Hashable, Sendable {
public var document: EditorDocument
public var lines: [EditorLine]
public var lineIndex: DocumentLineIndex
public var selection: EditorSelection
public var activeLineIndex: Int
public var lines: [EditorLine] {
lineIndex.editorLines(activeLineIndex: activeLineIndex)
}
public var lineCount: Int {
lineIndex.lineCount
}
public init(
document: EditorDocument,
selection: EditorSelection = EditorSelection(),
@ -96,10 +104,7 @@ public struct EditorState: Hashable, Sendable {
self.document = document
self.selection = selection
self.activeLineIndex = activeLineIndex
self.lines = EditorActiveLineTracker.lines(
from: document.source,
activeLineIndex: activeLineIndex
)
self.lineIndex = DocumentLineIndex(source: document.source)
}
public var hasUnsavedChanges: Bool {
@ -107,23 +112,28 @@ public struct EditorState: Hashable, Sendable {
}
public var activeColumnNumber: Int {
guard lines.indices.contains(activeLineIndex) else { return 1 }
let activeLine = lines[activeLineIndex]
let offset = selection.location - activeLine.range.location
return max(0, min(offset, activeLine.range.length)) + 1
guard let activeLineRange = lineIndex.lineContentRange(forLine: activeLineIndex) else { return 1 }
let offset = selection.location - activeLineRange.location
return max(0, min(offset, activeLineRange.length)) + 1
}
public mutating func updateSource(_ source: String) {
document.source = source
selection = EditorActiveLineTracker.clampedSelection(selection, in: source)
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: selection.location, in: source)
lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
lineIndex = DocumentLineIndex(source: source)
activeLineIndex = lineIndex.lineIndex(containing: selection.location)
}
public mutating func updateSource(_ edit: DocumentLineIndexEdit, selection newSelection: EditorSelection? = nil) {
lineIndex.replace(edit)
document.source = lineIndex.source
selection = EditorActiveLineTracker.clampedSelection(newSelection ?? selection, in: document.source)
activeLineIndex = lineIndex.lineIndex(containing: selection.location)
}
public mutating func updateSelection(_ selection: EditorSelection) {
self.selection = EditorActiveLineTracker.clampedSelection(selection, in: document.source)
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: self.selection.location, in: document.source)
lines = EditorActiveLineTracker.lines(from: document.source, activeLineIndex: activeLineIndex)
activeLineIndex = lineIndex.lineIndex(containing: self.selection.location)
}
public mutating func markSaved() {

View file

@ -85,6 +85,59 @@ public enum EditorDirtyLineInvalidator {
)
}
public static func plan(
previousText: String?,
currentLineIndex: DocumentLineIndex,
edit: DocumentLineIndexEdit?,
previousActiveLineIndex: Int?,
currentActiveLineIndex: Int
) -> EditorDirtyLineInvalidationPlan {
guard previousText != nil else {
return EditorDirtyLineInvalidationPlan(
reason: .initial,
isFullRender: true,
dirtyLineIndexes: Array(0..<currentLineIndex.lineCount)
)
}
var dirtyLineIndexes = Set<Int>()
let reason: EditorRenderReason
let changedRange: NSRange?
if let edit {
reason = .sourceChange
changedRange = edit.insertedRangeInEditedDocument
dirtyLineIndexes.formUnion(
currentLineIndex.lineIndexesAffected(
by: changedRange ?? NSRange(location: 0, length: 0)
)
)
} else if previousActiveLineIndex != currentActiveLineIndex {
reason = .activeLineChange
changedRange = nil
} else {
reason = .viewUpdate
changedRange = nil
}
if let previousActiveLineIndex,
previousActiveLineIndex != currentActiveLineIndex {
dirtyLineIndexes.insert(previousActiveLineIndex)
dirtyLineIndexes.insert(currentActiveLineIndex)
}
let validLineIndexes = dirtyLineIndexes
.filter { (0..<currentLineIndex.lineCount).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

View file

@ -115,7 +115,8 @@ public enum EditorBenchmarkProfiler {
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
let midpoint = source.utf16.count / 2
let activeLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
let lineIndex = DocumentLineIndex(source: source)
let activeLineIndex = lineIndex.lineIndex(containing: midpoint)
let documentResult = measure {
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
@ -171,11 +172,17 @@ public enum EditorBenchmarkProfiler {
referenceLinkLikeCount: profile.referenceLinkLikeCount
)
let typingEdit = DocumentLineIndexEdit(
range: NSRange(location: midpoint, length: 0),
replacement: "x"
)
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
let changedActiveLineIndex = EditorActiveLineTracker.lineIndex(containing: midpoint + 1, in: changedSource)
var changedLineIndex = lineIndex
changedLineIndex.replace(typingEdit)
let changedActiveLineIndex = changedLineIndex.lineIndex(containing: midpoint + 1)
let activeLineResult = measure {
EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
lineIndex.lineIndex(containing: midpoint)
}
measurements.append(EditorBenchmarkMeasurement(
name: "active_line_lookup",
@ -197,7 +204,8 @@ public enum EditorBenchmarkProfiler {
let dirtyClickResult = measure {
EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: source,
currentLineIndex: lineIndex,
edit: nil,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: changedActiveLineIndex
)
@ -212,7 +220,10 @@ public enum EditorBenchmarkProfiler {
let sourceUpdateResult = measure {
var updatedState = state
updatedState.updateSource(changedSource)
updatedState.updateSource(
typingEdit,
selection: EditorSelection(location: midpoint + typingEdit.replacementUTF16Length, length: 0)
)
}
measurements.append(EditorBenchmarkMeasurement(
name: "typing_state_update",
@ -224,7 +235,8 @@ public enum EditorBenchmarkProfiler {
let dirtyTypingResult = measure {
EditorDirtyLineInvalidator.plan(
previousText: source,
currentText: changedSource,
currentLineIndex: changedLineIndex,
edit: typingEdit,
previousActiveLineIndex: activeLineIndex,
currentActiveLineIndex: changedActiveLineIndex
)
@ -241,6 +253,9 @@ public enum EditorBenchmarkProfiler {
measurements.append(contentsOf: profileTextKit(
source: source,
changedSource: changedSource,
lineIndex: lineIndex,
changedLineIndex: changedLineIndex,
typingEdit: typingEdit,
activeLineIndex: activeLineIndex,
changedActiveLineIndex: changedActiveLineIndex,
dirtyClickPlan: dirtyClickPlan,
@ -342,6 +357,9 @@ public enum EditorBenchmarkProfiler {
private static func profileTextKit(
source: String,
changedSource: String,
lineIndex: DocumentLineIndex,
changedLineIndex: DocumentLineIndex,
typingEdit: DocumentLineIndexEdit,
activeLineIndex: Int,
changedActiveLineIndex: Int,
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
@ -368,13 +386,15 @@ public enum EditorBenchmarkProfiler {
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
previousText: nil,
currentText: source,
currentLineIndex: lineIndex,
edit: nil,
previousActiveLineIndex: nil,
currentActiveLineIndex: activeLineIndex
)
let attributedStringResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
lineIndex: lineIndex,
invalidationPlan: fullRenderPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
@ -491,6 +511,7 @@ public enum EditorBenchmarkProfiler {
if dirtyClickPlan.requiresStyling {
return MarkdownTextStyler.apply(
to: textStorage,
lineIndex: lineIndex,
invalidationPlan: dirtyClickPlan,
activeLineIndex: changedActiveLineIndex,
backgroundColor: .textBackgroundColor,
@ -525,6 +546,7 @@ public enum EditorBenchmarkProfiler {
let typingRenderResult = measure {
MarkdownTextStyler.apply(
to: textStorage,
lineIndex: changedLineIndex,
invalidationPlan: dirtyTypingPlan,
activeLineIndex: changedActiveLineIndex,
backgroundColor: .textBackgroundColor,
@ -591,6 +613,7 @@ public enum EditorBenchmarkProfiler {
))
_ = changedSource
_ = typingEdit
return measurements
}

View file

@ -33,6 +33,10 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
state.activeLineIndex
}
public var lineIndex: DocumentLineIndex {
state.lineIndex
}
public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document)
instrumentation = EditorInstrumentationSnapshot()
@ -46,6 +50,21 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSource(_ source: String, edit: DocumentLineIndexEdit?, selection: EditorSelection? = nil) {
guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
if let edit {
state.updateSource(edit, selection: selection)
} else {
state.updateSource(source)
if let selection {
state.updateSelection(selection)
}
}
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSelection(_ selection: EditorSelection) {
guard state.selection != selection else { return }
let previousActiveLineIndex = state.activeLineIndex
@ -107,6 +126,8 @@ public struct HybridMarkdownEditor: View, EditorView {
set: { viewModel.updateSelection($0) }
),
activeLineIndex: viewModel.state.activeLineIndex,
lineIndex: viewModel.state.lineIndex,
onTextEdit: viewModel.updateSource,
onRenderPass: viewModel.recordRenderPass
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
@ -114,7 +135,7 @@ public struct HybridMarkdownEditor: View, EditorView {
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
columnNumber: viewModel.state.activeColumnNumber,
lineCount: viewModel.state.lines.count,
lineCount: viewModel.state.lineCount,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
)
}
@ -150,6 +171,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
@ -210,6 +233,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
@ -231,22 +255,45 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var pendingEdit: DocumentLineIndexEdit?
private var didFocusTextView = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: NSTextView,
shouldChangeTextIn affectedCharRange: NSRange,
replacementString: String?
) -> Bool {
pendingEdit = DocumentLineIndexEdit(
range: affectedCharRange,
replacement: replacementString ?? ""
)
return true
}
func textDidChange(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
parent.selection = EditorSelection(range: textView.selectedRange())
let selection = EditorSelection(range: textView.selectedRange())
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit)
} else {
currentLineIndex = DocumentLineIndex(source: textView.string)
}
parent.onTextEdit(textView.string, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView)
pendingEdit = nil
}
func textViewDidChangeSelection(_ notification: Notification) {
@ -271,6 +318,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor,
@ -346,7 +394,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
@ -401,6 +450,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
@ -425,6 +476,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
if textView.text != text {
context.coordinator.performProgrammaticUpdate {
textView.text = text
@ -437,20 +489,40 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var pendingEdit: DocumentLineIndexEdit?
private var didFocusTextView = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
pendingEdit = DocumentLineIndexEdit(range: range, replacement: text)
return true
}
func textViewDidChange(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange)
let selection = EditorSelection(range: textView.selectedRange)
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit)
} else {
currentLineIndex = DocumentLineIndex(source: textView.text)
}
parent.onTextEdit(textView.text, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView)
pendingEdit = nil
}
func textViewDidChangeSelection(_ textView: UITextView) {
@ -473,6 +545,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textView.textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground,
@ -537,7 +610,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
@ -569,6 +643,7 @@ enum MarkdownTextStyler {
@discardableResult
static func apply(
to textStorage: NSTextStorage,
lineIndex: DocumentLineIndex,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int,
backgroundColor: PlatformColor,
@ -577,11 +652,9 @@ enum MarkdownTextStyler {
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) -> 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)
let fullRange = NSRange(location: 0, length: textStorage.length)
guard fullRange.length > 0 else {
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: lines.count)
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount)
}
textStorage.beginEditing()
@ -590,13 +663,11 @@ enum MarkdownTextStyler {
}
let renderer = HybridMarkdownLineRenderer()
let dirtyLineIndexes = Set(invalidationPlan.dirtyLineIndexes)
let lines = invalidationPlan.isFullRender
? lineIndex.editorLines(activeLineIndex: activeLineIndex)
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
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
@ -618,7 +689,7 @@ enum MarkdownTextStyler {
}
textStorage.endEditing()
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: styledLineCount)
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount)
}
private static func resetAttributes(

View file

@ -86,6 +86,81 @@ final class DocumentLineIndexTests: XCTestCase {
XCTAssertEqual(lines.map(\.mode), [.rendered, .source, .rendered])
}
func testOffsetAndLineMappingUsesCachedBoundaries() {
let source = "One\nTwo\r\nThree\rFour"
let index = DocumentLineIndex(source: source)
XCTAssertEqual(index.lineCount, 4)
XCTAssertEqual(index.lineStartOffset(forLine: 0), 0)
XCTAssertEqual(index.lineStartOffset(forLine: 1), 4)
XCTAssertEqual(index.lineStartOffset(forLine: 2), 9)
XCTAssertEqual(index.lineStartOffset(forLine: 3), 15)
XCTAssertEqual(index.lineIndex(containing: 0), 0)
XCTAssertEqual(index.lineIndex(containing: 4), 1)
XCTAssertEqual(index.lineIndex(containing: 14), 2)
XCTAssertEqual(index.lineIndex(containing: 15), 3)
}
func testIncrementalInsertionWithoutNewlineMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\nTwo\nThree",
range: NSRange(location: 5, length: 0),
replacement: " updated"
)
}
func testIncrementalInsertionWithLFMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\nTwo\nThree",
range: NSRange(location: 7, length: 0),
replacement: "\nInserted"
)
}
func testIncrementalInsertionWithCRLFMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\r\nTwo\r\nThree",
range: NSRange(location: 8, length: 0),
replacement: "\r\nInserted"
)
}
func testIncrementalDeletionAcrossLinesMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\nTwo\nThree\nFour",
range: NSRange(location: 2, length: 10),
replacement: ""
)
}
func testIncrementalReplacementAcrossMixedLineEndingsMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\nTwo\r\nThree\rFour",
range: NSRange(location: 3, length: 11),
replacement: "\r\nReplacement\n"
)
}
func testIncrementalEditAtCRLFBoundaryMatchesFullRebuild() {
assertIncrementalEdit(
source: "One\r\nTwo\r\nThree",
range: NSRange(location: 3, length: 2),
replacement: "\n"
)
}
func testIncrementalLargeDocumentEditMatchesFullRebuild() {
let source = (0..<10_000)
.map { "Line \($0)\r\n" }
.joined()
assertIncrementalEdit(
source: source,
range: NSRange(location: 42_000, length: 5),
replacement: "changed\nwith\nnew lines"
)
}
func testBenchmarkDocumentSegmentsIntoPhysicalLines() throws {
let url = URL(fileURLWithPath: "/Users/feror/Sapling/Docs/Benchmarks/5mb.md")
let source = try String(contentsOf: url, encoding: .utf8)
@ -114,4 +189,27 @@ final class DocumentLineIndexTests: XCTestCase {
XCTAssertEqual(index.boundaries.map(\.contentRange), expectedRanges, file: file, line: line)
XCTAssertEqual(index.boundaries.map(\.lineEnding), expectedEndings, file: file, line: line)
}
private func assertIncrementalEdit(
source: String,
range: NSRange,
replacement: String,
file: StaticString = #filePath,
line: UInt = #line
) {
var incremental = DocumentLineIndex(source: source)
incremental.replaceCharacters(in: range, with: replacement)
let rebuiltSource = (source as NSString).replacingCharacters(in: range, with: replacement)
let rebuilt = DocumentLineIndex(source: rebuiltSource)
XCTAssertEqual(incremental.source, rebuiltSource, file: file, line: line)
XCTAssertEqual(incremental.boundaries, rebuilt.boundaries, file: file, line: line)
XCTAssertEqual(
incremental.editorLines(activeLineIndex: 0).map(\.source),
rebuilt.editorLines(activeLineIndex: 0).map(\.source),
file: file,
line: line
)
}
}