Sapling/Sources/SaplingEditor/EditorDirtyLineInvalidation.swift

218 lines
7 KiB
Swift
Raw Normal View History

import Foundation
public struct EditorDirtyLineInvalidationPlan: Hashable, Sendable {
public var reason: EditorRenderReason
public var isFullRender: Bool
public var dirtyLineIndexes: [Int]
public var changedRange: NSRange?
public init(
reason: EditorRenderReason,
isFullRender: Bool,
dirtyLineIndexes: [Int],
changedRange: NSRange? = nil
) {
self.reason = reason
self.isFullRender = isFullRender
self.dirtyLineIndexes = dirtyLineIndexes
self.changedRange = changedRange
}
public var dirtyLineCount: Int {
dirtyLineIndexes.count
}
public var requiresStyling: Bool {
isFullRender || !dirtyLineIndexes.isEmpty
}
}
public enum EditorDirtyLineInvalidator {
public static func plan(
previousText: String?,
currentText: String,
previousActiveLineIndex: Int?,
currentActiveLineIndex: Int
) -> EditorDirtyLineInvalidationPlan {
let currentLines = EditorActiveLineTracker.lines(
from: currentText,
activeLineIndex: currentActiveLineIndex
)
guard let previousText else {
return EditorDirtyLineInvalidationPlan(
reason: .initial,
isFullRender: true,
dirtyLineIndexes: currentLines.map(\.index)
)
}
var dirtyLineIndexes = Set<Int>()
let reason: EditorRenderReason
var changedRange: NSRange?
if previousText != currentText {
reason = .sourceChange
changedRange = changedUTF16Range(from: previousText, to: currentText)
dirtyLineIndexes.formUnion(
lineIndexesAffected(
by: changedRange ?? NSRange(location: 0, length: currentText.utf16.count),
in: currentText,
lineCount: currentLines.count
)
)
} else if previousActiveLineIndex != currentActiveLineIndex {
reason = .activeLineChange
} else {
reason = .viewUpdate
}
if let previousActiveLineIndex,
previousActiveLineIndex != currentActiveLineIndex {
dirtyLineIndexes.insert(previousActiveLineIndex)
dirtyLineIndexes.insert(currentActiveLineIndex)
}
let validLineIndexes = dirtyLineIndexes
.filter { (0..<currentLines.count).contains($0) }
.sorted()
return EditorDirtyLineInvalidationPlan(
reason: reason,
isFullRender: false,
dirtyLineIndexes: validLineIndexes,
changedRange: changedRange
)
}
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
let commonLength = min(old.length, new.length)
var prefixLength = 0
while prefixLength < commonLength,
old.character(at: prefixLength) == new.character(at: prefixLength) {
prefixLength += 1
}
var oldSuffixStart = old.length
var newSuffixStart = new.length
while oldSuffixStart > prefixLength,
newSuffixStart > prefixLength,
old.character(at: oldSuffixStart - 1) == new.character(at: newSuffixStart - 1) {
oldSuffixStart -= 1
newSuffixStart -= 1
}
return NSRange(location: prefixLength, length: max(0, newSuffixStart - prefixLength))
}
private static func lineIndexesAffected(
by changedRange: NSRange,
in source: String,
lineCount: Int
) -> Set<Int> {
guard lineCount > 0 else { return [] }
let sourceLength = source.utf16.count
let startLocation = max(0, min(changedRange.location, sourceLength))
let endLocation: Int
if changedRange.length == 0 {
endLocation = startLocation
} else {
endLocation = max(startLocation, min(changedRange.upperBound - 1, sourceLength))
}
let startLine = EditorActiveLineTracker.lineIndex(containing: startLocation, in: source)
let endLine = EditorActiveLineTracker.lineIndex(containing: endLocation, in: source)
let lowerBound = max(0, min(startLine, endLine) - 1)
let upperBound = min(lineCount - 1, max(startLine, endLine) + 1)
return Set(lowerBound...upperBound)
}
}
public extension EditorDirtyLineInvalidationPlan {
func includingEditableRegionTransition(
from previousRegion: EditableRegion?,
to currentRegion: EditableRegion,
lineCount: Int
) -> EditorDirtyLineInvalidationPlan {
guard !isFullRender else { return self }
guard previousRegion != currentRegion else { return self }
var dirtyLineIndexes = Set(self.dirtyLineIndexes)
dirtyLineIndexes.formUnion(previousRegion?.lineIndexes ?? [])
dirtyLineIndexes.formUnion(currentRegion.lineIndexes)
return EditorDirtyLineInvalidationPlan(
reason: reason,
isFullRender: false,
dirtyLineIndexes: dirtyLineIndexes
.filter { (0..<lineCount).contains($0) }
.sorted(),
changedRange: changedRange
)
}
}
private extension NSRange {
var upperBound: Int {
location + length
}
}