diff --git a/Sources/SaplingEditor/DocumentLineIndex.swift b/Sources/SaplingEditor/DocumentLineIndex.swift index b0d9cb2..94e45db 100644 --- a/Sources/SaplingEditor/DocumentLineIndex.swift +++ b/Sources/SaplingEditor/DocumentLineIndex.swift @@ -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 + public var insertedLineRange: Range + 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, activeLineIndex: Int) -> [EditorLine] { + indexes.compactMap { editorLine(at: $0, activeLineIndex: activeLineIndex) } } public func editorLines(activeLineIndex: Int) -> [EditorLine] { - let nsSource = source as NSString - return boundaries.map { boundary in - EditorLine( - index: boundary.index, - source: nsSource.substring(with: boundary.contentRange), - range: boundary.contentRange, - mode: boundary.index == activeLineIndex ? .source : .rendered - ) + 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.. EditorLine { + let nsSource = source as NSString + 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 } diff --git a/Sources/SaplingEditor/EditorActiveLineTracker.swift b/Sources/SaplingEditor/EditorActiveLineTracker.swift index 5b218bd..2eda27a 100644 --- a/Sources/SaplingEditor/EditorActiveLineTracker.swift +++ b/Sources/SaplingEditor/EditorActiveLineTracker.swift @@ -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)) diff --git a/Sources/SaplingEditor/EditorArchitecture.swift b/Sources/SaplingEditor/EditorArchitecture.swift index 4a94e33..f500d39 100644 --- a/Sources/SaplingEditor/EditorArchitecture.swift +++ b/Sources/SaplingEditor/EditorArchitecture.swift @@ -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() { diff --git a/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift index 8c416d7..157b8d5 100644 --- a/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift +++ b/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift @@ -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..() + 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.. NSRange { let old = oldText as NSString let new = newText as NSString diff --git a/Sources/SaplingEditor/EditorPerformanceProfiling.swift b/Sources/SaplingEditor/EditorPerformanceProfiling.swift index e32e86a..8c30db4 100644 --- a/Sources/SaplingEditor/EditorPerformanceProfiling.swift +++ b/Sources/SaplingEditor/EditorPerformanceProfiling.swift @@ -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 } diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 7061720..0825f6e 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -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( diff --git a/Tests/SaplingEditorTests/DocumentLineIndexTests.swift b/Tests/SaplingEditorTests/DocumentLineIndexTests.swift index c6c6700..c8f37bd 100644 --- a/Tests/SaplingEditorTests/DocumentLineIndexTests.swift +++ b/Tests/SaplingEditorTests/DocumentLineIndexTests.swift @@ -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 + ) + } }