refactor(renderer): formalize rendered lifecycle
This commit is contained in:
parent
fc21b9d1bd
commit
0dd8351847
3 changed files with 569 additions and 58 deletions
304
Sources/SaplingEditor/DocumentPresentationState.swift
Normal file
304
Sources/SaplingEditor/DocumentPresentationState.swift
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import Foundation
|
||||
|
||||
public enum RenderedLineState: String, Hashable, Sendable {
|
||||
case source
|
||||
case rendered
|
||||
}
|
||||
|
||||
public struct RenderedHeadingElement: Hashable, Sendable {
|
||||
public var lineIndex: Int
|
||||
public var level: Int
|
||||
public var markerRange: NSRange
|
||||
public var textRange: NSRange
|
||||
}
|
||||
|
||||
public struct RenderedTaskElement: Hashable, Sendable {
|
||||
public var lineIndex: Int
|
||||
public var markerRange: NSRange
|
||||
public var checkboxRange: NSRange
|
||||
public var contentRange: NSRange
|
||||
public var checked: Bool
|
||||
public var nestingLevel: Int
|
||||
}
|
||||
|
||||
public struct RenderedCodeBlockElement: Hashable, Sendable {
|
||||
public var lineIndexes: [Int]
|
||||
public var sourceRange: NSRange
|
||||
public var languageRange: NSRange?
|
||||
}
|
||||
|
||||
public struct RenderedLinkElement: Hashable, Sendable {
|
||||
public var lineIndex: Int
|
||||
public var titleRange: NSRange
|
||||
public var urlRange: NSRange?
|
||||
}
|
||||
|
||||
public enum RenderedDocumentElement: Hashable, Sendable {
|
||||
case heading(RenderedHeadingElement)
|
||||
case task(RenderedTaskElement)
|
||||
case codeBlock(RenderedCodeBlockElement)
|
||||
case link(RenderedLinkElement)
|
||||
case inlineCode(lineIndex: Int, range: NSRange)
|
||||
case blockquote(lineIndex: Int, range: NSRange)
|
||||
case horizontalRule(lineIndex: Int, range: NSRange)
|
||||
case listItem(lineIndex: Int, markerRange: NSRange, contentRange: NSRange, nestingLevel: Int)
|
||||
case tableRow(lineIndex: Int, range: NSRange)
|
||||
case paragraph(lineIndex: Int, range: NSRange)
|
||||
}
|
||||
|
||||
public struct DocumentPresentationLine: Hashable, Sendable {
|
||||
public var line: EditorLine
|
||||
public var state: RenderedLineState
|
||||
public var renderPlan: HybridMarkdownLineRenderPlan
|
||||
public var elements: [RenderedDocumentElement]
|
||||
}
|
||||
|
||||
public struct DocumentPresentationState: Hashable, Sendable {
|
||||
public var activeLineIndex: Int
|
||||
public var lineCount: Int
|
||||
public var lines: [DocumentPresentationLine]
|
||||
public var elements: [RenderedDocumentElement]
|
||||
|
||||
public init(
|
||||
lineIndex: DocumentLineIndex,
|
||||
activeLineIndex: Int,
|
||||
lineIndexes: [Int]? = nil
|
||||
) {
|
||||
self.activeLineIndex = activeLineIndex
|
||||
self.lineCount = lineIndex.lineCount
|
||||
|
||||
let renderer = HybridMarkdownLineRenderer()
|
||||
let selectedLines = lineIndexes.map {
|
||||
lineIndex.editorLines(for: $0, activeLineIndex: activeLineIndex)
|
||||
} ?? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
||||
let renderPlans = Self.renderPlans(
|
||||
for: selectedLines,
|
||||
lineIndex: lineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
renderer: renderer,
|
||||
needsDocumentContext: lineIndexes != nil
|
||||
)
|
||||
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
||||
var presentationLines: [DocumentPresentationLine] = []
|
||||
var collectedElements: [RenderedDocumentElement] = []
|
||||
|
||||
for line in selectedLines {
|
||||
let state: RenderedLineState = line.index == activeLineIndex ? .source : .rendered
|
||||
let renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line)
|
||||
let lineElements = Self.elements(for: renderPlan, state: state)
|
||||
presentationLines.append(DocumentPresentationLine(
|
||||
line: line,
|
||||
state: state,
|
||||
renderPlan: renderPlan,
|
||||
elements: lineElements
|
||||
))
|
||||
collectedElements.append(contentsOf: lineElements)
|
||||
}
|
||||
|
||||
if lineIndexes == nil {
|
||||
collectedElements.append(contentsOf: Self.codeBlockElements(from: renderPlans))
|
||||
}
|
||||
|
||||
self.lines = presentationLines
|
||||
self.elements = collectedElements
|
||||
}
|
||||
|
||||
public var renderedTasks: [RenderedTaskElement] {
|
||||
elements.compactMap {
|
||||
guard case .task(let task) = $0 else { return nil }
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
public var renderedCodeBlocks: [RenderedCodeBlockElement] {
|
||||
elements.compactMap {
|
||||
guard case .codeBlock(let codeBlock) = $0 else { return nil }
|
||||
return codeBlock
|
||||
}
|
||||
}
|
||||
|
||||
public func lineState(at lineIndex: Int) -> RenderedLineState? {
|
||||
lines.first { $0.line.index == lineIndex }?.state
|
||||
}
|
||||
|
||||
public static func renderedTasks(
|
||||
in lineIndex: DocumentLineIndex,
|
||||
activeLineIndex: Int
|
||||
) -> [RenderedTaskElement] {
|
||||
guard lineIndex.source.contains("[ ]")
|
||||
|| lineIndex.source.contains("[x]")
|
||||
|| lineIndex.source.contains("[X]")
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
return DocumentPresentationState(
|
||||
lineIndex: lineIndex,
|
||||
activeLineIndex: activeLineIndex
|
||||
).renderedTasks
|
||||
}
|
||||
|
||||
private static func renderPlans(
|
||||
for lines: [EditorLine],
|
||||
lineIndex: DocumentLineIndex,
|
||||
activeLineIndex: Int,
|
||||
renderer: HybridMarkdownLineRenderer,
|
||||
needsDocumentContext: Bool
|
||||
) -> [HybridMarkdownLineRenderPlan] {
|
||||
if needsDocumentContext {
|
||||
guard linesNeedCodeBlockContext(lines, lineIndex: lineIndex) else {
|
||||
return renderer.renderPlans(for: lines)
|
||||
}
|
||||
return renderer.renderPlans(
|
||||
for: lines,
|
||||
resolvingCodeBlockContextWith: lineIndex,
|
||||
activeLineIndex: activeLineIndex
|
||||
)
|
||||
}
|
||||
return renderer.renderPlans(for: lines)
|
||||
}
|
||||
|
||||
private static func linesNeedCodeBlockContext(_ lines: [EditorLine], lineIndex: DocumentLineIndex) -> Bool {
|
||||
for line in lines {
|
||||
let lowerBound = max(0, line.index - 2)
|
||||
let upperBound = min(lineIndex.lineCount - 1, line.index + 2)
|
||||
for nearbyLineIndex in lowerBound...upperBound {
|
||||
guard let nearbyLine = lineIndex.editorLine(at: nearbyLineIndex, activeLineIndex: -1) else { continue }
|
||||
if isFenceLine(nearbyLine.source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isFenceLine(_ source: String) -> Bool {
|
||||
let trimmedPrefix = source.prefix { $0 == " " || $0 == "\t" }
|
||||
guard trimmedPrefix.count <= 3 else { return false }
|
||||
let content = source.dropFirst(trimmedPrefix.count)
|
||||
return content.hasPrefix("```") || content.hasPrefix("~~~")
|
||||
}
|
||||
|
||||
private static func elements(
|
||||
for renderPlan: HybridMarkdownLineRenderPlan,
|
||||
state: RenderedLineState
|
||||
) -> [RenderedDocumentElement] {
|
||||
guard state == .rendered else { return [] }
|
||||
|
||||
var elements: [RenderedDocumentElement] = []
|
||||
switch renderPlan.kind {
|
||||
case .heading(let level, let markerRange, let textRange):
|
||||
elements.append(.heading(RenderedHeadingElement(
|
||||
lineIndex: renderPlan.line.index,
|
||||
level: level,
|
||||
markerRange: markerRange,
|
||||
textRange: textRange
|
||||
)))
|
||||
case .blockquote:
|
||||
elements.append(.blockquote(lineIndex: renderPlan.line.index, range: renderPlan.line.range))
|
||||
case .horizontalRule(let range):
|
||||
elements.append(.horizontalRule(lineIndex: renderPlan.line.index, range: range))
|
||||
case .unorderedList(let markerRange, let contentRange, let nestingLevel),
|
||||
.orderedList(let markerRange, let contentRange, let nestingLevel):
|
||||
elements.append(.listItem(
|
||||
lineIndex: renderPlan.line.index,
|
||||
markerRange: markerRange,
|
||||
contentRange: contentRange,
|
||||
nestingLevel: nestingLevel
|
||||
))
|
||||
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
||||
elements.append(.task(RenderedTaskElement(
|
||||
lineIndex: renderPlan.line.index,
|
||||
markerRange: markerRange,
|
||||
checkboxRange: checkboxRange,
|
||||
contentRange: contentRange,
|
||||
checked: checked,
|
||||
nestingLevel: nestingLevel
|
||||
)))
|
||||
case .fencedCodeFence, .codeBlockContent:
|
||||
break
|
||||
case .tableRow:
|
||||
elements.append(.tableRow(lineIndex: renderPlan.line.index, range: renderPlan.line.range))
|
||||
case .paragraph:
|
||||
break
|
||||
}
|
||||
|
||||
for span in renderPlan.spans {
|
||||
switch span.kind {
|
||||
case .inlineCode:
|
||||
elements.append(.inlineCode(lineIndex: renderPlan.line.index, range: span.range))
|
||||
case .link:
|
||||
elements.append(.link(RenderedLinkElement(
|
||||
lineIndex: renderPlan.line.index,
|
||||
titleRange: span.range,
|
||||
urlRange: nil
|
||||
)))
|
||||
case .automaticLink:
|
||||
elements.append(.link(RenderedLinkElement(
|
||||
lineIndex: renderPlan.line.index,
|
||||
titleRange: span.range,
|
||||
urlRange: span.range
|
||||
)))
|
||||
case .bold, .italic, .markdownDelimiter:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
private static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] {
|
||||
var elements: [RenderedDocumentElement] = []
|
||||
var openLineIndexes: [Int] = []
|
||||
var openRange: NSRange?
|
||||
var openLanguageRange: NSRange?
|
||||
|
||||
for plan in plans {
|
||||
guard case .fencedCodeFence(let markerRange, let languageRange) = plan.kind else {
|
||||
if openRange != nil {
|
||||
openLineIndexes.append(plan.line.index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if let sourceRange = openRange {
|
||||
openLineIndexes.append(plan.line.index)
|
||||
elements.append(.codeBlock(RenderedCodeBlockElement(
|
||||
lineIndexes: openLineIndexes,
|
||||
sourceRange: NSRange(
|
||||
location: sourceRange.location,
|
||||
length: plan.line.range.upperBound - sourceRange.location
|
||||
),
|
||||
languageRange: openLanguageRange
|
||||
)))
|
||||
openLineIndexes.removeAll()
|
||||
openRange = nil
|
||||
openLanguageRange = nil
|
||||
} else {
|
||||
openLineIndexes = [plan.line.index]
|
||||
openRange = markerRange
|
||||
openLanguageRange = languageRange
|
||||
}
|
||||
}
|
||||
|
||||
if let sourceRange = openRange,
|
||||
let lastLine = plans.last?.line {
|
||||
elements.append(.codeBlock(RenderedCodeBlockElement(
|
||||
lineIndexes: openLineIndexes,
|
||||
sourceRange: NSRange(
|
||||
location: sourceRange.location,
|
||||
length: lastLine.range.upperBound - sourceRange.location
|
||||
),
|
||||
languageRange: openLanguageRange
|
||||
)))
|
||||
}
|
||||
|
||||
return elements
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSRange {
|
||||
var upperBound: Int {
|
||||
location + length
|
||||
}
|
||||
}
|
||||
|
|
@ -261,6 +261,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
private var lastStyledActiveLineIndex: Int?
|
||||
private var pendingEdit: DocumentLineIndexEdit?
|
||||
private var didFocusTextView = false
|
||||
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||
|
||||
init(_ parent: NativeMarkdownTextView) {
|
||||
self.parent = parent
|
||||
|
|
@ -307,7 +308,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
|
||||
func applyHybridAttributes(to textView: NSTextView) {
|
||||
guard let textStorage = textView.textStorage else { return }
|
||||
let invalidationPlan = invalidationPlan(for: textView.string)
|
||||
let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
||||
let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex)
|
||||
guard invalidationPlan.requiresStyling else { return }
|
||||
|
||||
let selectedRange = textView.selectedRange()
|
||||
|
|
@ -320,12 +322,13 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
to: textStorage,
|
||||
lineIndex: currentLineIndex,
|
||||
invalidationPlan: invalidationPlan,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
backgroundColor: .textBackgroundColor,
|
||||
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||
textColor: .labelColor,
|
||||
secondaryTextColor: .secondaryLabelColor,
|
||||
accentColor: .controlAccentColor
|
||||
accentColor: .controlAccentColor,
|
||||
usesRenderedControls: true
|
||||
)
|
||||
if textView.selectedRange() != selectedRange,
|
||||
selectedRange.location <= textView.string.utf16.count {
|
||||
|
|
@ -335,14 +338,20 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
lastStyledText = textView.string
|
||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||
lastStyledActiveLineIndex = activeLineIndex
|
||||
syncChecklistControls(
|
||||
in: textView,
|
||||
stylingResult: stylingResult,
|
||||
invalidationPlan: invalidationPlan,
|
||||
activeLineIndex: activeLineIndex
|
||||
)
|
||||
parent.onRenderPass(EditorRenderPassMetric(
|
||||
reason: invalidationPlan.reason,
|
||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||
characterCount: textView.string.utf16.count,
|
||||
lineCount: stylingResult.totalLineCount,
|
||||
dirtyLineCount: stylingResult.styledLineCount,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
isFullRender: invalidationPlan.isFullRender,
|
||||
restoredScrollPosition: didRestoreVisibleOrigin
|
||||
))
|
||||
|
|
@ -385,22 +394,120 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
|||
func invalidateStylingCache() {
|
||||
lastStyledText = nil
|
||||
lastStyledActiveLineIndex = nil
|
||||
removeChecklistControls()
|
||||
}
|
||||
|
||||
private var isPerformingProgrammaticUpdate: Bool {
|
||||
programmaticUpdateDepth > 0
|
||||
}
|
||||
|
||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
||||
EditorDirtyLineInvalidator.plan(
|
||||
previousText: lastStyledText,
|
||||
currentLineIndex: currentLineIndex,
|
||||
edit: pendingEdit,
|
||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||
currentActiveLineIndex: parent.activeLineIndex
|
||||
currentActiveLineIndex: activeLineIndex
|
||||
)
|
||||
}
|
||||
|
||||
private func syncChecklistControls(
|
||||
in textView: NSTextView,
|
||||
stylingResult: MarkdownTextStylingResult,
|
||||
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
||||
activeLineIndex: Int
|
||||
) {
|
||||
let shouldRebuildAll = invalidationPlan.isFullRender || invalidationPlan.reason == .sourceChange
|
||||
let renderedTasks = shouldRebuildAll
|
||||
? DocumentPresentationState.renderedTasks(in: currentLineIndex, activeLineIndex: activeLineIndex)
|
||||
: stylingResult.renderedTasks
|
||||
let tasksByLine = Dictionary(uniqueKeysWithValues: renderedTasks.map { ($0.lineIndex, $0) })
|
||||
|
||||
if shouldRebuildAll {
|
||||
let validLineIndexes = Set(tasksByLine.keys)
|
||||
for lineIndex in Array(checklistButtons.keys) where !validLineIndexes.contains(lineIndex) {
|
||||
removeChecklistControl(at: lineIndex)
|
||||
}
|
||||
} else {
|
||||
for lineIndex in stylingResult.styledLineIndexes where tasksByLine[lineIndex] == nil {
|
||||
removeChecklistControl(at: lineIndex)
|
||||
}
|
||||
}
|
||||
|
||||
for task in renderedTasks {
|
||||
let button = checklistButtons[task.lineIndex] ?? ChecklistOverlayButton()
|
||||
button.task = task
|
||||
button.onToggle = { [weak self, weak textView, weak button] in
|
||||
guard let task = button?.task,
|
||||
let textView
|
||||
else { return }
|
||||
self?.toggleTask(task, in: textView)
|
||||
}
|
||||
button.state = task.checked ? .on : .off
|
||||
button.toolTip = task.checked ? "Mark task incomplete" : "Mark task complete"
|
||||
if button.superview !== textView {
|
||||
textView.addSubview(button)
|
||||
}
|
||||
checklistButtons[task.lineIndex] = button
|
||||
}
|
||||
|
||||
for (lineIndex, button) in Array(checklistButtons) {
|
||||
guard let task = button.task,
|
||||
let frame = checklistFrame(for: task, in: textView)
|
||||
else {
|
||||
removeChecklistControl(at: lineIndex)
|
||||
continue
|
||||
}
|
||||
button.frame = frame
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
|
||||
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
|
||||
|
||||
let replacement = task.checked ? "[ ]" : "[x]"
|
||||
pendingEdit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement)
|
||||
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
|
||||
pendingEdit = nil
|
||||
return
|
||||
}
|
||||
|
||||
textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement)
|
||||
textView.setSelectedRange(NSRange(location: task.checkboxRange.upperBound, length: 0))
|
||||
textView.didChangeText()
|
||||
textView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
|
||||
private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? {
|
||||
guard let layoutManager = textView.layoutManager,
|
||||
let textContainer = textView.textContainer,
|
||||
task.checkboxRange.location < textView.string.utf16.count
|
||||
else { return nil }
|
||||
|
||||
let characterRange = NSRange(location: task.checkboxRange.location, length: 1)
|
||||
let glyphRange = layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil)
|
||||
guard glyphRange.length > 0 else { return nil }
|
||||
|
||||
let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
||||
let origin = textView.textContainerOrigin
|
||||
return NSRect(
|
||||
x: origin.x + glyphRect.minX - 2,
|
||||
y: origin.y + glyphRect.minY - 1,
|
||||
width: 18,
|
||||
height: 18
|
||||
)
|
||||
}
|
||||
|
||||
private func removeChecklistControl(at lineIndex: Int) {
|
||||
checklistButtons[lineIndex]?.removeFromSuperview()
|
||||
checklistButtons.removeValue(forKey: lineIndex)
|
||||
}
|
||||
|
||||
private func removeChecklistControls() {
|
||||
checklistButtons.values.forEach { $0.removeFromSuperview() }
|
||||
checklistButtons.removeAll()
|
||||
}
|
||||
|
||||
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
|
||||
guard let origin,
|
||||
let scrollView = textView.enclosingScrollView
|
||||
|
|
@ -425,6 +532,30 @@ private final class EditorTextView: NSTextView {
|
|||
}
|
||||
}
|
||||
|
||||
private final class ChecklistOverlayButton: NSButton {
|
||||
var task: RenderedTaskElement?
|
||||
var onToggle: (() -> Void)?
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
setButtonType(.switch)
|
||||
title = ""
|
||||
isBordered = false
|
||||
imagePosition = .imageOnly
|
||||
target = self
|
||||
action = #selector(toggleCheckbox)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
@objc private func toggleCheckbox() {
|
||||
onToggle?()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ComfortableEditorScrollView: NSScrollView {
|
||||
weak var editorTextView: NSTextView?
|
||||
|
||||
|
|
@ -534,7 +665,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func applyHybridAttributes(to textView: UITextView) {
|
||||
let invalidationPlan = invalidationPlan(for: textView.text)
|
||||
let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange.location)
|
||||
let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex)
|
||||
guard invalidationPlan.requiresStyling else { return }
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
|
|
@ -547,7 +679,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
to: textView.textStorage,
|
||||
lineIndex: currentLineIndex,
|
||||
invalidationPlan: invalidationPlan,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
backgroundColor: .systemBackground,
|
||||
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
||||
textColor: .label,
|
||||
|
|
@ -563,14 +695,14 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
}
|
||||
|
||||
lastStyledText = textView.text
|
||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
||||
lastStyledActiveLineIndex = activeLineIndex
|
||||
parent.onRenderPass(EditorRenderPassMetric(
|
||||
reason: invalidationPlan.reason,
|
||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||
characterCount: textView.text.utf16.count,
|
||||
lineCount: stylingResult.totalLineCount,
|
||||
dirtyLineCount: stylingResult.styledLineCount,
|
||||
activeLineIndex: parent.activeLineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
isFullRender: invalidationPlan.isFullRender,
|
||||
restoredScrollPosition: didRestoreContentOffset
|
||||
))
|
||||
|
|
@ -607,13 +739,13 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
programmaticUpdateDepth > 0
|
||||
}
|
||||
|
||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
||||
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
||||
EditorDirtyLineInvalidator.plan(
|
||||
previousText: lastStyledText,
|
||||
currentLineIndex: currentLineIndex,
|
||||
edit: pendingEdit,
|
||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||
currentActiveLineIndex: parent.activeLineIndex
|
||||
currentActiveLineIndex: activeLineIndex
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -629,8 +761,15 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
|||
struct MarkdownTextStylingResult {
|
||||
var totalLineCount: Int
|
||||
var styledLineCount: Int
|
||||
var styledLineIndexes: [Int]
|
||||
var renderedTasks: [RenderedTaskElement]
|
||||
|
||||
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
|
||||
static let empty = MarkdownTextStylingResult(
|
||||
totalLineCount: 0,
|
||||
styledLineCount: 0,
|
||||
styledLineIndexes: [],
|
||||
renderedTasks: []
|
||||
)
|
||||
}
|
||||
|
||||
enum MarkdownTextStyler {
|
||||
|
|
@ -650,11 +789,17 @@ enum MarkdownTextStyler {
|
|||
activeLineBackgroundColor: PlatformColor,
|
||||
textColor: PlatformColor,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
accentColor: PlatformColor,
|
||||
usesRenderedControls: Bool = false
|
||||
) -> MarkdownTextStylingResult {
|
||||
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||
guard fullRange.length > 0 else {
|
||||
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount)
|
||||
return MarkdownTextStylingResult(
|
||||
totalLineCount: lineIndex.lineCount,
|
||||
styledLineCount: lineIndex.lineCount,
|
||||
styledLineIndexes: Array(0..<lineIndex.lineCount),
|
||||
renderedTasks: []
|
||||
)
|
||||
}
|
||||
|
||||
textStorage.beginEditing()
|
||||
|
|
@ -662,65 +807,47 @@ enum MarkdownTextStyler {
|
|||
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
||||
}
|
||||
|
||||
let renderer = HybridMarkdownLineRenderer()
|
||||
let lines = invalidationPlan.isFullRender
|
||||
? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
||||
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
|
||||
let renderPlans = invalidationPlan.isFullRender || !linesNeedCodeBlockContext(lines, lineIndex: lineIndex)
|
||||
? renderer.renderPlans(for: lines)
|
||||
: renderer.renderPlans(
|
||||
for: lines,
|
||||
resolvingCodeBlockContextWith: lineIndex,
|
||||
activeLineIndex: activeLineIndex
|
||||
let presentationState = DocumentPresentationState(
|
||||
lineIndex: lineIndex,
|
||||
activeLineIndex: activeLineIndex,
|
||||
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
|
||||
)
|
||||
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
||||
var styledLineCount = 0
|
||||
for line in lines {
|
||||
var styledLineIndexes: [Int] = []
|
||||
for presentationLine in presentationState.lines {
|
||||
let line = presentationLine.line
|
||||
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
||||
styledLineCount += 1
|
||||
styledLineIndexes.append(line.index)
|
||||
|
||||
if line.index == activeLineIndex {
|
||||
switch presentationLine.state {
|
||||
case .source:
|
||||
textStorage.addAttributes([
|
||||
.backgroundColor: activeLineBackgroundColor,
|
||||
.font: monospacedFont(size: 15, weight: .regular)
|
||||
], range: line.range)
|
||||
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
||||
} else {
|
||||
case .rendered:
|
||||
styleRenderedLine(
|
||||
in: textStorage,
|
||||
line: line,
|
||||
renderPlan: renderPlansByLine[line.index] ?? renderer.renderPlan(for: line),
|
||||
renderPlan: presentationLine.renderPlan,
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor
|
||||
accentColor: accentColor,
|
||||
usesRenderedControls: usesRenderedControls
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
textStorage.endEditing()
|
||||
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount)
|
||||
}
|
||||
|
||||
private static func linesNeedCodeBlockContext(_ lines: [EditorLine], lineIndex: DocumentLineIndex) -> Bool {
|
||||
for line in lines {
|
||||
let lowerBound = max(0, line.index - 2)
|
||||
let upperBound = min(lineIndex.lineCount - 1, line.index + 2)
|
||||
for nearbyLineIndex in lowerBound...upperBound {
|
||||
guard let nearbyLine = lineIndex.editorLine(at: nearbyLineIndex, activeLineIndex: -1) else { continue }
|
||||
if isFenceLine(nearbyLine.source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isFenceLine(_ source: String) -> Bool {
|
||||
let trimmedPrefix = source.prefix { $0 == " " || $0 == "\t" }
|
||||
guard trimmedPrefix.count <= 3 else { return false }
|
||||
let content = source.dropFirst(trimmedPrefix.count)
|
||||
return content.hasPrefix("```") || content.hasPrefix("~~~")
|
||||
return MarkdownTextStylingResult(
|
||||
totalLineCount: presentationState.lineCount,
|
||||
styledLineCount: styledLineCount,
|
||||
styledLineIndexes: styledLineIndexes,
|
||||
renderedTasks: presentationState.renderedTasks
|
||||
)
|
||||
}
|
||||
|
||||
private static func resetAttributes(
|
||||
|
|
@ -739,7 +866,8 @@ enum MarkdownTextStyler {
|
|||
textColor: PlatformColor,
|
||||
backgroundColor: PlatformColor,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor
|
||||
accentColor: PlatformColor,
|
||||
usesRenderedControls: Bool
|
||||
) {
|
||||
guard line.range.length > 0 else { return }
|
||||
|
||||
|
|
@ -801,7 +929,8 @@ enum MarkdownTextStyler {
|
|||
nestingLevel: nestingLevel,
|
||||
secondaryTextColor: secondaryTextColor,
|
||||
accentColor: accentColor,
|
||||
backgroundColor: backgroundColor
|
||||
backgroundColor: backgroundColor,
|
||||
usesRenderedControls: usesRenderedControls
|
||||
)
|
||||
if checked {
|
||||
textStorage.addAttributes([
|
||||
|
|
@ -1010,7 +1139,8 @@ enum MarkdownTextStyler {
|
|||
nestingLevel: Int,
|
||||
secondaryTextColor: PlatformColor,
|
||||
accentColor: PlatformColor,
|
||||
backgroundColor: PlatformColor
|
||||
backgroundColor: PlatformColor,
|
||||
usesRenderedControls: Bool
|
||||
) {
|
||||
textStorage.addAttributes([
|
||||
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
||||
|
|
@ -1024,6 +1154,9 @@ enum MarkdownTextStyler {
|
|||
.font: monospacedFont(size: 15, weight: .semibold),
|
||||
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
|
||||
], range: checkboxRange)
|
||||
if usesRenderedControls {
|
||||
hideSyntax(in: textStorage, range: checkboxRange)
|
||||
}
|
||||
textStorage.addAttributes([
|
||||
.font: systemFont(size: 16, weight: .regular)
|
||||
], range: contentRange)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import XCTest
|
||||
@testable import SaplingEditor
|
||||
|
||||
final class DocumentPresentationStateTests: XCTestCase {
|
||||
func testEveryLineHasExactlyOnePresentationState() {
|
||||
let source = "# Heading\n* [x] Done\nParagraph"
|
||||
let lineIndex = DocumentLineIndex(source: source)
|
||||
|
||||
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 1)
|
||||
|
||||
XCTAssertEqual(presentation.lines.count, 3)
|
||||
XCTAssertEqual(presentation.lineState(at: 0), .rendered)
|
||||
XCTAssertEqual(presentation.lineState(at: 1), .source)
|
||||
XCTAssertEqual(presentation.lineState(at: 2), .rendered)
|
||||
XCTAssertEqual(presentation.lines.filter { $0.state == .source }.map(\.line.index), [1])
|
||||
XCTAssertEqual(presentation.lines.filter { $0.state == .rendered }.map(\.line.index), [0, 2])
|
||||
}
|
||||
|
||||
func testPresentationStateIsDeterministicForSameDocumentAndActiveLine() {
|
||||
let source = "# Heading\nThis has **bold** and `code`.\n* [ ] Todo"
|
||||
let lineIndex = DocumentLineIndex(source: source)
|
||||
|
||||
let first = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
||||
let second = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
||||
|
||||
XCTAssertEqual(first, second)
|
||||
}
|
||||
|
||||
func testRenderedElementsAreSemantic() {
|
||||
let source = "# Heading\n* [x] Done\nSee [Docs](https://example.com)"
|
||||
let lineIndex = DocumentLineIndex(source: source)
|
||||
|
||||
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 99)
|
||||
|
||||
XCTAssertTrue(presentation.elements.contains {
|
||||
guard case .heading(let heading) = $0 else { return false }
|
||||
return heading.lineIndex == 0 && heading.level == 1
|
||||
})
|
||||
XCTAssertEqual(presentation.renderedTasks.map(\.checked), [true])
|
||||
XCTAssertTrue(presentation.elements.contains {
|
||||
guard case .link(let link) = $0 else { return false }
|
||||
return (source as NSString).substring(with: link.titleRange) == "Docs"
|
||||
})
|
||||
}
|
||||
|
||||
func testCodeBlockIsRepresentedAsSemanticBlock() {
|
||||
let source = "Intro\n```swift\nlet value = 42\n```"
|
||||
let lineIndex = DocumentLineIndex(source: source)
|
||||
|
||||
let presentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 0)
|
||||
|
||||
XCTAssertEqual(presentation.renderedCodeBlocks.count, 1)
|
||||
XCTAssertEqual(presentation.renderedCodeBlocks[0].lineIndexes, [1, 2, 3])
|
||||
XCTAssertEqual(
|
||||
(source as NSString).substring(with: presentation.renderedCodeBlocks[0].languageRange!),
|
||||
"swift"
|
||||
)
|
||||
}
|
||||
|
||||
func testDirtyPresentationResolvesNearbyCodeBlockContext() {
|
||||
let source = "Intro\n```swift\nlet value = 42\n```"
|
||||
let lineIndex = DocumentLineIndex(source: source)
|
||||
|
||||
let presentation = DocumentPresentationState(
|
||||
lineIndex: lineIndex,
|
||||
activeLineIndex: 0,
|
||||
lineIndexes: [2]
|
||||
)
|
||||
|
||||
XCTAssertEqual(presentation.lines.count, 1)
|
||||
XCTAssertEqual(presentation.lines[0].state, .rendered)
|
||||
XCTAssertEqual(presentation.lines[0].renderPlan.kind, .codeBlockContent)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue