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 struct DocumentLineIndex: Hashable, Sendable {
|
||||||
public var source: String
|
public var source: String
|
||||||
public var boundaries: [DocumentLineBoundary]
|
public var boundaries: [DocumentLineBoundary]
|
||||||
|
|
@ -47,59 +75,185 @@ public struct DocumentLineIndex: Hashable, Sendable {
|
||||||
self.boundaries = Self.scanBoundaries(in: source)
|
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 {
|
public func lineIndex(containing location: Int) -> Int {
|
||||||
guard !boundaries.isEmpty else { return 0 }
|
guard !boundaries.isEmpty else { return 0 }
|
||||||
|
|
||||||
let clampedLocation = max(0, min(location, source.utf16.count))
|
let clampedLocation = max(0, min(location, source.utf16.count))
|
||||||
for boundary in boundaries.dropLast() {
|
|
||||||
if clampedLocation < boundary.nextLineLocation {
|
var lowerBound = 0
|
||||||
return boundary.index
|
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] {
|
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
|
let nsSource = source as NSString
|
||||||
return boundaries.map { boundary in
|
return EditorLine(
|
||||||
EditorLine(
|
|
||||||
index: boundary.index,
|
index: boundary.index,
|
||||||
source: nsSource.substring(with: boundary.contentRange),
|
source: nsSource.substring(with: boundary.contentRange),
|
||||||
range: boundary.contentRange,
|
range: boundary.contentRange,
|
||||||
mode: boundary.index == activeLineIndex ? .source : .rendered
|
mode: boundary.index == activeLineIndex ? .source : .rendered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private static func scanBoundaries(in source: String) -> [DocumentLineBoundary] {
|
private static func scanBoundaries(in source: String) -> [DocumentLineBoundary] {
|
||||||
let nsSource = source as NSString
|
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 {
|
guard sourceLength > 0 else {
|
||||||
return [
|
return [
|
||||||
DocumentLineBoundary(
|
DocumentLineBoundary(
|
||||||
index: 0,
|
index: startingIndex,
|
||||||
contentRange: NSRange(location: 0, length: 0),
|
contentRange: NSRange(location: range.location, length: 0),
|
||||||
lineEnding: .none
|
lineEnding: .none
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
var boundaries: [DocumentLineBoundary] = []
|
var boundaries: [DocumentLineBoundary] = []
|
||||||
var lineStart = 0
|
var lineStart = range.location
|
||||||
var lineIndex = 0
|
var lineIndex = startingIndex
|
||||||
var endedWithLineEnding = false
|
var endedWithLineEnding = false
|
||||||
|
|
||||||
while lineStart < sourceLength {
|
while lineStart < sourceLength {
|
||||||
var cursor = lineStart
|
var cursor = lineStart
|
||||||
while cursor < sourceLength,
|
while cursor < sourceLength,
|
||||||
!isLineEndingStart(nsSource.character(at: cursor)) {
|
!isLineEndingStart(source.character(at: cursor)) {
|
||||||
cursor += 1
|
cursor += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
let contentRange = NSRange(location: lineStart, length: cursor - lineStart)
|
let contentRange = NSRange(location: lineStart, length: cursor - lineStart)
|
||||||
if cursor < sourceLength {
|
if cursor < sourceLength {
|
||||||
let lineEnding = lineEndingStrategy(at: cursor, in: nsSource)
|
let lineEnding = lineEndingStrategy(at: cursor, in: source, scanEnd: sourceLength)
|
||||||
boundaries.append(DocumentLineBoundary(
|
boundaries.append(DocumentLineBoundary(
|
||||||
index: lineIndex,
|
index: lineIndex,
|
||||||
contentRange: contentRange,
|
contentRange: contentRange,
|
||||||
|
|
@ -121,7 +275,7 @@ public struct DocumentLineIndex: Hashable, Sendable {
|
||||||
lineIndex += 1
|
lineIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if endedWithLineEnding {
|
if endedWithLineEnding, includeTrailingBlankLine {
|
||||||
boundaries.append(DocumentLineBoundary(
|
boundaries.append(DocumentLineBoundary(
|
||||||
index: lineIndex,
|
index: lineIndex,
|
||||||
contentRange: NSRange(location: sourceLength, length: 0),
|
contentRange: NSRange(location: sourceLength, length: 0),
|
||||||
|
|
@ -132,11 +286,12 @@ public struct DocumentLineIndex: Hashable, Sendable {
|
||||||
return boundaries
|
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)
|
let character = source.character(at: location)
|
||||||
if character == carriageReturnUTF16 {
|
if character == carriageReturnUTF16 {
|
||||||
let nextLocation = location + 1
|
let nextLocation = location + 1
|
||||||
if nextLocation < source.length,
|
let upperBound = scanEnd ?? source.length
|
||||||
|
if nextLocation < upperBound,
|
||||||
source.character(at: nextLocation) == lineFeedUTF16 {
|
source.character(at: nextLocation) == lineFeedUTF16 {
|
||||||
return .crlf
|
return .crlf
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,18 @@ public enum EditorActiveLineTracker {
|
||||||
DocumentLineIndex(source: source).editorLines(activeLineIndex: activeLineIndex)
|
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 {
|
public static func lineIndex(containing location: Int, in source: String) -> Int {
|
||||||
DocumentLineIndex(source: source).lineIndex(containing: location)
|
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 {
|
public static func clampedSelection(_ selection: EditorSelection, in source: String) -> EditorSelection {
|
||||||
let sourceLength = source.utf16.count
|
let sourceLength = source.utf16.count
|
||||||
let location = max(0, min(selection.location, sourceLength))
|
let location = max(0, min(selection.location, sourceLength))
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,18 @@ public struct EditorSelection: Hashable, Codable, Sendable {
|
||||||
|
|
||||||
public struct EditorState: Hashable, Sendable {
|
public struct EditorState: Hashable, Sendable {
|
||||||
public var document: EditorDocument
|
public var document: EditorDocument
|
||||||
public var lines: [EditorLine]
|
public var lineIndex: DocumentLineIndex
|
||||||
public var selection: EditorSelection
|
public var selection: EditorSelection
|
||||||
public var activeLineIndex: Int
|
public var activeLineIndex: Int
|
||||||
|
|
||||||
|
public var lines: [EditorLine] {
|
||||||
|
lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var lineCount: Int {
|
||||||
|
lineIndex.lineCount
|
||||||
|
}
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
document: EditorDocument,
|
document: EditorDocument,
|
||||||
selection: EditorSelection = EditorSelection(),
|
selection: EditorSelection = EditorSelection(),
|
||||||
|
|
@ -96,10 +104,7 @@ public struct EditorState: Hashable, Sendable {
|
||||||
self.document = document
|
self.document = document
|
||||||
self.selection = selection
|
self.selection = selection
|
||||||
self.activeLineIndex = activeLineIndex
|
self.activeLineIndex = activeLineIndex
|
||||||
self.lines = EditorActiveLineTracker.lines(
|
self.lineIndex = DocumentLineIndex(source: document.source)
|
||||||
from: document.source,
|
|
||||||
activeLineIndex: activeLineIndex
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var hasUnsavedChanges: Bool {
|
public var hasUnsavedChanges: Bool {
|
||||||
|
|
@ -107,23 +112,28 @@ public struct EditorState: Hashable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var activeColumnNumber: Int {
|
public var activeColumnNumber: Int {
|
||||||
guard lines.indices.contains(activeLineIndex) else { return 1 }
|
guard let activeLineRange = lineIndex.lineContentRange(forLine: activeLineIndex) else { return 1 }
|
||||||
let activeLine = lines[activeLineIndex]
|
let offset = selection.location - activeLineRange.location
|
||||||
let offset = selection.location - activeLine.range.location
|
return max(0, min(offset, activeLineRange.length)) + 1
|
||||||
return max(0, min(offset, activeLine.range.length)) + 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func updateSource(_ source: String) {
|
public mutating func updateSource(_ source: String) {
|
||||||
document.source = source
|
document.source = source
|
||||||
selection = EditorActiveLineTracker.clampedSelection(selection, in: source)
|
selection = EditorActiveLineTracker.clampedSelection(selection, in: source)
|
||||||
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: selection.location, in: source)
|
lineIndex = DocumentLineIndex(source: source)
|
||||||
lines = EditorActiveLineTracker.lines(from: source, activeLineIndex: activeLineIndex)
|
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) {
|
public mutating func updateSelection(_ selection: EditorSelection) {
|
||||||
self.selection = EditorActiveLineTracker.clampedSelection(selection, in: document.source)
|
self.selection = EditorActiveLineTracker.clampedSelection(selection, in: document.source)
|
||||||
activeLineIndex = EditorActiveLineTracker.lineIndex(containing: self.selection.location, in: document.source)
|
activeLineIndex = lineIndex.lineIndex(containing: self.selection.location)
|
||||||
lines = EditorActiveLineTracker.lines(from: document.source, activeLineIndex: activeLineIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func markSaved() {
|
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 {
|
private static func changedUTF16Range(from oldText: String, to newText: String) -> NSRange {
|
||||||
let old = oldText as NSString
|
let old = oldText as NSString
|
||||||
let new = newText as NSString
|
let new = newText as NSString
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,8 @@ public enum EditorBenchmarkProfiler {
|
||||||
|
|
||||||
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
|
let profile = documentProfile(fileName: url.lastPathComponent, source: source)
|
||||||
let midpoint = source.utf16.count / 2
|
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 {
|
let documentResult = measure {
|
||||||
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
|
MarkdownDocument(url: url, title: url.deletingPathExtension().lastPathComponent, content: source)
|
||||||
|
|
@ -171,11 +172,17 @@ public enum EditorBenchmarkProfiler {
|
||||||
referenceLinkLikeCount: profile.referenceLinkLikeCount
|
referenceLinkLikeCount: profile.referenceLinkLikeCount
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let typingEdit = DocumentLineIndexEdit(
|
||||||
|
range: NSRange(location: midpoint, length: 0),
|
||||||
|
replacement: "x"
|
||||||
|
)
|
||||||
let changedSource = sourceByInsertingProbeText(in: source, at: midpoint)
|
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 {
|
let activeLineResult = measure {
|
||||||
EditorActiveLineTracker.lineIndex(containing: midpoint, in: source)
|
lineIndex.lineIndex(containing: midpoint)
|
||||||
}
|
}
|
||||||
measurements.append(EditorBenchmarkMeasurement(
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
name: "active_line_lookup",
|
name: "active_line_lookup",
|
||||||
|
|
@ -197,7 +204,8 @@ public enum EditorBenchmarkProfiler {
|
||||||
let dirtyClickResult = measure {
|
let dirtyClickResult = measure {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: source,
|
previousText: source,
|
||||||
currentText: source,
|
currentLineIndex: lineIndex,
|
||||||
|
edit: nil,
|
||||||
previousActiveLineIndex: activeLineIndex,
|
previousActiveLineIndex: activeLineIndex,
|
||||||
currentActiveLineIndex: changedActiveLineIndex
|
currentActiveLineIndex: changedActiveLineIndex
|
||||||
)
|
)
|
||||||
|
|
@ -212,7 +220,10 @@ public enum EditorBenchmarkProfiler {
|
||||||
|
|
||||||
let sourceUpdateResult = measure {
|
let sourceUpdateResult = measure {
|
||||||
var updatedState = state
|
var updatedState = state
|
||||||
updatedState.updateSource(changedSource)
|
updatedState.updateSource(
|
||||||
|
typingEdit,
|
||||||
|
selection: EditorSelection(location: midpoint + typingEdit.replacementUTF16Length, length: 0)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
measurements.append(EditorBenchmarkMeasurement(
|
measurements.append(EditorBenchmarkMeasurement(
|
||||||
name: "typing_state_update",
|
name: "typing_state_update",
|
||||||
|
|
@ -224,7 +235,8 @@ public enum EditorBenchmarkProfiler {
|
||||||
let dirtyTypingResult = measure {
|
let dirtyTypingResult = measure {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: source,
|
previousText: source,
|
||||||
currentText: changedSource,
|
currentLineIndex: changedLineIndex,
|
||||||
|
edit: typingEdit,
|
||||||
previousActiveLineIndex: activeLineIndex,
|
previousActiveLineIndex: activeLineIndex,
|
||||||
currentActiveLineIndex: changedActiveLineIndex
|
currentActiveLineIndex: changedActiveLineIndex
|
||||||
)
|
)
|
||||||
|
|
@ -241,6 +253,9 @@ public enum EditorBenchmarkProfiler {
|
||||||
measurements.append(contentsOf: profileTextKit(
|
measurements.append(contentsOf: profileTextKit(
|
||||||
source: source,
|
source: source,
|
||||||
changedSource: changedSource,
|
changedSource: changedSource,
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
changedLineIndex: changedLineIndex,
|
||||||
|
typingEdit: typingEdit,
|
||||||
activeLineIndex: activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
changedActiveLineIndex: changedActiveLineIndex,
|
changedActiveLineIndex: changedActiveLineIndex,
|
||||||
dirtyClickPlan: dirtyClickPlan,
|
dirtyClickPlan: dirtyClickPlan,
|
||||||
|
|
@ -342,6 +357,9 @@ public enum EditorBenchmarkProfiler {
|
||||||
private static func profileTextKit(
|
private static func profileTextKit(
|
||||||
source: String,
|
source: String,
|
||||||
changedSource: String,
|
changedSource: String,
|
||||||
|
lineIndex: DocumentLineIndex,
|
||||||
|
changedLineIndex: DocumentLineIndex,
|
||||||
|
typingEdit: DocumentLineIndexEdit,
|
||||||
activeLineIndex: Int,
|
activeLineIndex: Int,
|
||||||
changedActiveLineIndex: Int,
|
changedActiveLineIndex: Int,
|
||||||
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
|
dirtyClickPlan: EditorDirtyLineInvalidationPlan,
|
||||||
|
|
@ -368,13 +386,15 @@ public enum EditorBenchmarkProfiler {
|
||||||
|
|
||||||
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
|
let fullRenderPlan = EditorDirtyLineInvalidator.plan(
|
||||||
previousText: nil,
|
previousText: nil,
|
||||||
currentText: source,
|
currentLineIndex: lineIndex,
|
||||||
|
edit: nil,
|
||||||
previousActiveLineIndex: nil,
|
previousActiveLineIndex: nil,
|
||||||
currentActiveLineIndex: activeLineIndex
|
currentActiveLineIndex: activeLineIndex
|
||||||
)
|
)
|
||||||
let attributedStringResult = measure {
|
let attributedStringResult = measure {
|
||||||
MarkdownTextStyler.apply(
|
MarkdownTextStyler.apply(
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
|
lineIndex: lineIndex,
|
||||||
invalidationPlan: fullRenderPlan,
|
invalidationPlan: fullRenderPlan,
|
||||||
activeLineIndex: activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
|
|
@ -491,6 +511,7 @@ public enum EditorBenchmarkProfiler {
|
||||||
if dirtyClickPlan.requiresStyling {
|
if dirtyClickPlan.requiresStyling {
|
||||||
return MarkdownTextStyler.apply(
|
return MarkdownTextStyler.apply(
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
|
lineIndex: lineIndex,
|
||||||
invalidationPlan: dirtyClickPlan,
|
invalidationPlan: dirtyClickPlan,
|
||||||
activeLineIndex: changedActiveLineIndex,
|
activeLineIndex: changedActiveLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
|
|
@ -525,6 +546,7 @@ public enum EditorBenchmarkProfiler {
|
||||||
let typingRenderResult = measure {
|
let typingRenderResult = measure {
|
||||||
MarkdownTextStyler.apply(
|
MarkdownTextStyler.apply(
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
|
lineIndex: changedLineIndex,
|
||||||
invalidationPlan: dirtyTypingPlan,
|
invalidationPlan: dirtyTypingPlan,
|
||||||
activeLineIndex: changedActiveLineIndex,
|
activeLineIndex: changedActiveLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
|
|
@ -591,6 +613,7 @@ public enum EditorBenchmarkProfiler {
|
||||||
))
|
))
|
||||||
|
|
||||||
_ = changedSource
|
_ = changedSource
|
||||||
|
_ = typingEdit
|
||||||
return measurements
|
return measurements
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
|
||||||
state.activeLineIndex
|
state.activeLineIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var lineIndex: DocumentLineIndex {
|
||||||
|
state.lineIndex
|
||||||
|
}
|
||||||
|
|
||||||
public func replaceDocument(_ document: EditorDocument) {
|
public func replaceDocument(_ document: EditorDocument) {
|
||||||
state = EditorState(document: document)
|
state = EditorState(document: document)
|
||||||
instrumentation = EditorInstrumentationSnapshot()
|
instrumentation = EditorInstrumentationSnapshot()
|
||||||
|
|
@ -46,6 +50,21 @@ public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordi
|
||||||
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
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) {
|
public func updateSelection(_ selection: EditorSelection) {
|
||||||
guard state.selection != selection else { return }
|
guard state.selection != selection else { return }
|
||||||
let previousActiveLineIndex = state.activeLineIndex
|
let previousActiveLineIndex = state.activeLineIndex
|
||||||
|
|
@ -107,6 +126,8 @@ public struct HybridMarkdownEditor: View, EditorView {
|
||||||
set: { viewModel.updateSelection($0) }
|
set: { viewModel.updateSelection($0) }
|
||||||
),
|
),
|
||||||
activeLineIndex: viewModel.state.activeLineIndex,
|
activeLineIndex: viewModel.state.activeLineIndex,
|
||||||
|
lineIndex: viewModel.state.lineIndex,
|
||||||
|
onTextEdit: viewModel.updateSource,
|
||||||
onRenderPass: viewModel.recordRenderPass
|
onRenderPass: viewModel.recordRenderPass
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
@ -114,7 +135,7 @@ public struct HybridMarkdownEditor: View, EditorView {
|
||||||
EditorStatusBar(
|
EditorStatusBar(
|
||||||
activeLineIndex: viewModel.state.activeLineIndex,
|
activeLineIndex: viewModel.state.activeLineIndex,
|
||||||
columnNumber: viewModel.state.activeColumnNumber,
|
columnNumber: viewModel.state.activeColumnNumber,
|
||||||
lineCount: viewModel.state.lines.count,
|
lineCount: viewModel.state.lineCount,
|
||||||
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
|
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +171,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var selection: EditorSelection
|
@Binding var selection: EditorSelection
|
||||||
let activeLineIndex: Int
|
let activeLineIndex: Int
|
||||||
|
let lineIndex: DocumentLineIndex
|
||||||
|
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
|
||||||
let onRenderPass: (EditorRenderPassMetric) -> Void
|
let onRenderPass: (EditorRenderPassMetric) -> Void
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
|
|
@ -210,6 +233,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||||
context.coordinator.parent = self
|
context.coordinator.parent = self
|
||||||
|
context.coordinator.currentLineIndex = lineIndex
|
||||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
|
|
||||||
if textView.string != text {
|
if textView.string != text {
|
||||||
|
|
@ -231,22 +255,45 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
var parent: NativeMarkdownTextView
|
var parent: NativeMarkdownTextView
|
||||||
|
var currentLineIndex: DocumentLineIndex
|
||||||
private var programmaticUpdateDepth = 0
|
private var programmaticUpdateDepth = 0
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var didFocusTextView = false
|
private var didFocusTextView = false
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
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) {
|
func textDidChange(_ notification: Notification) {
|
||||||
guard !isPerformingProgrammaticUpdate else { return }
|
guard !isPerformingProgrammaticUpdate else { return }
|
||||||
guard let textView = notification.object as? NSTextView else { return }
|
guard let textView = notification.object as? NSTextView else { return }
|
||||||
|
|
||||||
parent.text = textView.string
|
let selection = EditorSelection(range: textView.selectedRange())
|
||||||
parent.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)
|
applyHybridAttributes(to: textView)
|
||||||
|
pendingEdit = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ notification: Notification) {
|
func textViewDidChangeSelection(_ notification: Notification) {
|
||||||
|
|
@ -271,6 +318,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
performProgrammaticUpdate {
|
performProgrammaticUpdate {
|
||||||
stylingResult = MarkdownTextStyler.apply(
|
stylingResult = MarkdownTextStyler.apply(
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
|
lineIndex: currentLineIndex,
|
||||||
invalidationPlan: invalidationPlan,
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: parent.activeLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
|
|
@ -346,7 +394,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentText: text,
|
currentLineIndex: currentLineIndex,
|
||||||
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||||
currentActiveLineIndex: parent.activeLineIndex
|
currentActiveLineIndex: parent.activeLineIndex
|
||||||
)
|
)
|
||||||
|
|
@ -401,6 +450,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var selection: EditorSelection
|
@Binding var selection: EditorSelection
|
||||||
let activeLineIndex: Int
|
let activeLineIndex: Int
|
||||||
|
let lineIndex: DocumentLineIndex
|
||||||
|
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
|
||||||
let onRenderPass: (EditorRenderPassMetric) -> Void
|
let onRenderPass: (EditorRenderPassMetric) -> Void
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
|
|
@ -425,6 +476,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
|
|
||||||
func updateUIView(_ textView: UITextView, context: Context) {
|
func updateUIView(_ textView: UITextView, context: Context) {
|
||||||
context.coordinator.parent = self
|
context.coordinator.parent = self
|
||||||
|
context.coordinator.currentLineIndex = lineIndex
|
||||||
if textView.text != text {
|
if textView.text != text {
|
||||||
context.coordinator.performProgrammaticUpdate {
|
context.coordinator.performProgrammaticUpdate {
|
||||||
textView.text = text
|
textView.text = text
|
||||||
|
|
@ -437,20 +489,40 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
|
|
||||||
final class Coordinator: NSObject, UITextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
var parent: NativeMarkdownTextView
|
var parent: NativeMarkdownTextView
|
||||||
|
var currentLineIndex: DocumentLineIndex
|
||||||
private var programmaticUpdateDepth = 0
|
private var programmaticUpdateDepth = 0
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var didFocusTextView = false
|
private var didFocusTextView = false
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
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) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
guard !isPerformingProgrammaticUpdate else { return }
|
guard !isPerformingProgrammaticUpdate else { return }
|
||||||
parent.text = textView.text
|
let selection = EditorSelection(range: textView.selectedRange)
|
||||||
parent.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)
|
applyHybridAttributes(to: textView)
|
||||||
|
pendingEdit = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||||
|
|
@ -473,6 +545,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
performProgrammaticUpdate {
|
performProgrammaticUpdate {
|
||||||
stylingResult = MarkdownTextStyler.apply(
|
stylingResult = MarkdownTextStyler.apply(
|
||||||
to: textView.textStorage,
|
to: textView.textStorage,
|
||||||
|
lineIndex: currentLineIndex,
|
||||||
invalidationPlan: invalidationPlan,
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: parent.activeLineIndex,
|
||||||
backgroundColor: .systemBackground,
|
backgroundColor: .systemBackground,
|
||||||
|
|
@ -537,7 +610,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentText: text,
|
currentLineIndex: currentLineIndex,
|
||||||
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||||
currentActiveLineIndex: parent.activeLineIndex
|
currentActiveLineIndex: parent.activeLineIndex
|
||||||
)
|
)
|
||||||
|
|
@ -569,6 +643,7 @@ enum MarkdownTextStyler {
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func apply(
|
static func apply(
|
||||||
to textStorage: NSTextStorage,
|
to textStorage: NSTextStorage,
|
||||||
|
lineIndex: DocumentLineIndex,
|
||||||
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
||||||
activeLineIndex: Int,
|
activeLineIndex: Int,
|
||||||
backgroundColor: PlatformColor,
|
backgroundColor: PlatformColor,
|
||||||
|
|
@ -577,11 +652,9 @@ enum MarkdownTextStyler {
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor
|
accentColor: PlatformColor
|
||||||
) -> MarkdownTextStylingResult {
|
) -> MarkdownTextStylingResult {
|
||||||
let source = textStorage.string as NSString
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||||
let fullRange = NSRange(location: 0, length: source.length)
|
|
||||||
let lines = EditorActiveLineTracker.lines(from: source as String, activeLineIndex: activeLineIndex)
|
|
||||||
guard fullRange.length > 0 else {
|
guard fullRange.length > 0 else {
|
||||||
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: lines.count)
|
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
textStorage.beginEditing()
|
textStorage.beginEditing()
|
||||||
|
|
@ -590,13 +663,11 @@ enum MarkdownTextStyler {
|
||||||
}
|
}
|
||||||
|
|
||||||
let renderer = HybridMarkdownLineRenderer()
|
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
|
var styledLineCount = 0
|
||||||
for line in lines {
|
for line in lines {
|
||||||
guard invalidationPlan.isFullRender || dirtyLineIndexes.contains(line.index) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
||||||
styledLineCount += 1
|
styledLineCount += 1
|
||||||
|
|
||||||
|
|
@ -618,7 +689,7 @@ enum MarkdownTextStyler {
|
||||||
}
|
}
|
||||||
|
|
||||||
textStorage.endEditing()
|
textStorage.endEditing()
|
||||||
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: styledLineCount)
|
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func resetAttributes(
|
private static func resetAttributes(
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,81 @@ final class DocumentLineIndexTests: XCTestCase {
|
||||||
XCTAssertEqual(lines.map(\.mode), [.rendered, .source, .rendered])
|
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 {
|
func testBenchmarkDocumentSegmentsIntoPhysicalLines() throws {
|
||||||
let url = URL(fileURLWithPath: "/Users/feror/Sapling/Docs/Benchmarks/5mb.md")
|
let url = URL(fileURLWithPath: "/Users/feror/Sapling/Docs/Benchmarks/5mb.md")
|
||||||
let source = try String(contentsOf: url, encoding: .utf8)
|
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(\.contentRange), expectedRanges, file: file, line: line)
|
||||||
XCTAssertEqual(index.boundaries.map(\.lineEnding), expectedEndings, 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