perf(editor): introduce incremental line index
This commit is contained in:
parent
6426dc494a
commit
a29844dfbc
7 changed files with 477 additions and 59 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue