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 lastStyledActiveLineIndex: Int?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var didFocusTextView = false
|
private var didFocusTextView = false
|
||||||
|
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
|
|
@ -307,7 +308,8 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: NSTextView) {
|
func applyHybridAttributes(to textView: NSTextView) {
|
||||||
guard let textStorage = textView.textStorage else { return }
|
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 }
|
guard invalidationPlan.requiresStyling else { return }
|
||||||
|
|
||||||
let selectedRange = textView.selectedRange()
|
let selectedRange = textView.selectedRange()
|
||||||
|
|
@ -320,12 +322,13 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
to: textStorage,
|
to: textStorage,
|
||||||
lineIndex: currentLineIndex,
|
lineIndex: currentLineIndex,
|
||||||
invalidationPlan: invalidationPlan,
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
backgroundColor: .textBackgroundColor,
|
backgroundColor: .textBackgroundColor,
|
||||||
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
||||||
textColor: .labelColor,
|
textColor: .labelColor,
|
||||||
secondaryTextColor: .secondaryLabelColor,
|
secondaryTextColor: .secondaryLabelColor,
|
||||||
accentColor: .controlAccentColor
|
accentColor: .controlAccentColor,
|
||||||
|
usesRenderedControls: true
|
||||||
)
|
)
|
||||||
if textView.selectedRange() != selectedRange,
|
if textView.selectedRange() != selectedRange,
|
||||||
selectedRange.location <= textView.string.utf16.count {
|
selectedRange.location <= textView.string.utf16.count {
|
||||||
|
|
@ -335,14 +338,20 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
lastStyledText = textView.string
|
lastStyledText = textView.string
|
||||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
lastStyledActiveLineIndex = activeLineIndex
|
||||||
|
syncChecklistControls(
|
||||||
|
in: textView,
|
||||||
|
stylingResult: stylingResult,
|
||||||
|
invalidationPlan: invalidationPlan,
|
||||||
|
activeLineIndex: activeLineIndex
|
||||||
|
)
|
||||||
parent.onRenderPass(EditorRenderPassMetric(
|
parent.onRenderPass(EditorRenderPassMetric(
|
||||||
reason: invalidationPlan.reason,
|
reason: invalidationPlan.reason,
|
||||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||||
characterCount: textView.string.utf16.count,
|
characterCount: textView.string.utf16.count,
|
||||||
lineCount: stylingResult.totalLineCount,
|
lineCount: stylingResult.totalLineCount,
|
||||||
dirtyLineCount: stylingResult.styledLineCount,
|
dirtyLineCount: stylingResult.styledLineCount,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
isFullRender: invalidationPlan.isFullRender,
|
isFullRender: invalidationPlan.isFullRender,
|
||||||
restoredScrollPosition: didRestoreVisibleOrigin
|
restoredScrollPosition: didRestoreVisibleOrigin
|
||||||
))
|
))
|
||||||
|
|
@ -385,22 +394,120 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
func invalidateStylingCache() {
|
func invalidateStylingCache() {
|
||||||
lastStyledText = nil
|
lastStyledText = nil
|
||||||
lastStyledActiveLineIndex = nil
|
lastStyledActiveLineIndex = nil
|
||||||
|
removeChecklistControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPerformingProgrammaticUpdate: Bool {
|
private var isPerformingProgrammaticUpdate: Bool {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentLineIndex: currentLineIndex,
|
currentLineIndex: currentLineIndex,
|
||||||
edit: pendingEdit,
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
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 {
|
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
|
||||||
guard let origin,
|
guard let origin,
|
||||||
let scrollView = textView.enclosingScrollView
|
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 {
|
private final class ComfortableEditorScrollView: NSScrollView {
|
||||||
weak var editorTextView: NSTextView?
|
weak var editorTextView: NSTextView?
|
||||||
|
|
||||||
|
|
@ -534,7 +665,8 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: UITextView) {
|
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 }
|
guard invalidationPlan.requiresStyling else { return }
|
||||||
|
|
||||||
let selectedRange = textView.selectedRange
|
let selectedRange = textView.selectedRange
|
||||||
|
|
@ -547,7 +679,7 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
to: textView.textStorage,
|
to: textView.textStorage,
|
||||||
lineIndex: currentLineIndex,
|
lineIndex: currentLineIndex,
|
||||||
invalidationPlan: invalidationPlan,
|
invalidationPlan: invalidationPlan,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
backgroundColor: .systemBackground,
|
backgroundColor: .systemBackground,
|
||||||
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
|
||||||
textColor: .label,
|
textColor: .label,
|
||||||
|
|
@ -563,14 +695,14 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
lastStyledText = textView.text
|
lastStyledText = textView.text
|
||||||
lastStyledActiveLineIndex = parent.activeLineIndex
|
lastStyledActiveLineIndex = activeLineIndex
|
||||||
parent.onRenderPass(EditorRenderPassMetric(
|
parent.onRenderPass(EditorRenderPassMetric(
|
||||||
reason: invalidationPlan.reason,
|
reason: invalidationPlan.reason,
|
||||||
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
||||||
characterCount: textView.text.utf16.count,
|
characterCount: textView.text.utf16.count,
|
||||||
lineCount: stylingResult.totalLineCount,
|
lineCount: stylingResult.totalLineCount,
|
||||||
dirtyLineCount: stylingResult.styledLineCount,
|
dirtyLineCount: stylingResult.styledLineCount,
|
||||||
activeLineIndex: parent.activeLineIndex,
|
activeLineIndex: activeLineIndex,
|
||||||
isFullRender: invalidationPlan.isFullRender,
|
isFullRender: invalidationPlan.isFullRender,
|
||||||
restoredScrollPosition: didRestoreContentOffset
|
restoredScrollPosition: didRestoreContentOffset
|
||||||
))
|
))
|
||||||
|
|
@ -607,13 +739,13 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
programmaticUpdateDepth > 0
|
programmaticUpdateDepth > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
|
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
||||||
EditorDirtyLineInvalidator.plan(
|
EditorDirtyLineInvalidator.plan(
|
||||||
previousText: lastStyledText,
|
previousText: lastStyledText,
|
||||||
currentLineIndex: currentLineIndex,
|
currentLineIndex: currentLineIndex,
|
||||||
edit: pendingEdit,
|
edit: pendingEdit,
|
||||||
previousActiveLineIndex: lastStyledActiveLineIndex,
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
||||||
currentActiveLineIndex: parent.activeLineIndex
|
currentActiveLineIndex: activeLineIndex
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -629,8 +761,15 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
struct MarkdownTextStylingResult {
|
struct MarkdownTextStylingResult {
|
||||||
var totalLineCount: Int
|
var totalLineCount: Int
|
||||||
var styledLineCount: 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 {
|
enum MarkdownTextStyler {
|
||||||
|
|
@ -650,11 +789,17 @@ enum MarkdownTextStyler {
|
||||||
activeLineBackgroundColor: PlatformColor,
|
activeLineBackgroundColor: PlatformColor,
|
||||||
textColor: PlatformColor,
|
textColor: PlatformColor,
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor
|
accentColor: PlatformColor,
|
||||||
|
usesRenderedControls: Bool = false
|
||||||
) -> MarkdownTextStylingResult {
|
) -> MarkdownTextStylingResult {
|
||||||
let fullRange = NSRange(location: 0, length: textStorage.length)
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
||||||
guard fullRange.length > 0 else {
|
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()
|
textStorage.beginEditing()
|
||||||
|
|
@ -662,65 +807,47 @@ enum MarkdownTextStyler {
|
||||||
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
let renderer = HybridMarkdownLineRenderer()
|
let presentationState = DocumentPresentationState(
|
||||||
let lines = invalidationPlan.isFullRender
|
lineIndex: lineIndex,
|
||||||
? lineIndex.editorLines(activeLineIndex: activeLineIndex)
|
activeLineIndex: activeLineIndex,
|
||||||
: lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex)
|
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
|
||||||
let renderPlans = invalidationPlan.isFullRender || !linesNeedCodeBlockContext(lines, lineIndex: lineIndex)
|
|
||||||
? renderer.renderPlans(for: lines)
|
|
||||||
: renderer.renderPlans(
|
|
||||||
for: lines,
|
|
||||||
resolvingCodeBlockContextWith: lineIndex,
|
|
||||||
activeLineIndex: activeLineIndex
|
|
||||||
)
|
)
|
||||||
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
|
||||||
var styledLineCount = 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)
|
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
||||||
styledLineCount += 1
|
styledLineCount += 1
|
||||||
|
styledLineIndexes.append(line.index)
|
||||||
|
|
||||||
if line.index == activeLineIndex {
|
switch presentationLine.state {
|
||||||
|
case .source:
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.backgroundColor: activeLineBackgroundColor,
|
.backgroundColor: activeLineBackgroundColor,
|
||||||
.font: monospacedFont(size: 15, weight: .regular)
|
.font: monospacedFont(size: 15, weight: .regular)
|
||||||
], range: line.range)
|
], range: line.range)
|
||||||
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
||||||
} else {
|
case .rendered:
|
||||||
styleRenderedLine(
|
styleRenderedLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
line: line,
|
line: line,
|
||||||
renderPlan: renderPlansByLine[line.index] ?? renderer.renderPlan(for: line),
|
renderPlan: presentationLine.renderPlan,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
accentColor: accentColor
|
accentColor: accentColor,
|
||||||
|
usesRenderedControls: usesRenderedControls
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textStorage.endEditing()
|
textStorage.endEditing()
|
||||||
return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount)
|
return MarkdownTextStylingResult(
|
||||||
}
|
totalLineCount: presentationState.lineCount,
|
||||||
|
styledLineCount: styledLineCount,
|
||||||
private static func linesNeedCodeBlockContext(_ lines: [EditorLine], lineIndex: DocumentLineIndex) -> Bool {
|
styledLineIndexes: styledLineIndexes,
|
||||||
for line in lines {
|
renderedTasks: presentationState.renderedTasks
|
||||||
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 resetAttributes(
|
private static func resetAttributes(
|
||||||
|
|
@ -739,7 +866,8 @@ enum MarkdownTextStyler {
|
||||||
textColor: PlatformColor,
|
textColor: PlatformColor,
|
||||||
backgroundColor: PlatformColor,
|
backgroundColor: PlatformColor,
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor
|
accentColor: PlatformColor,
|
||||||
|
usesRenderedControls: Bool
|
||||||
) {
|
) {
|
||||||
guard line.range.length > 0 else { return }
|
guard line.range.length > 0 else { return }
|
||||||
|
|
||||||
|
|
@ -801,7 +929,8 @@ enum MarkdownTextStyler {
|
||||||
nestingLevel: nestingLevel,
|
nestingLevel: nestingLevel,
|
||||||
secondaryTextColor: secondaryTextColor,
|
secondaryTextColor: secondaryTextColor,
|
||||||
accentColor: accentColor,
|
accentColor: accentColor,
|
||||||
backgroundColor: backgroundColor
|
backgroundColor: backgroundColor,
|
||||||
|
usesRenderedControls: usesRenderedControls
|
||||||
)
|
)
|
||||||
if checked {
|
if checked {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
|
|
@ -1010,7 +1139,8 @@ enum MarkdownTextStyler {
|
||||||
nestingLevel: Int,
|
nestingLevel: Int,
|
||||||
secondaryTextColor: PlatformColor,
|
secondaryTextColor: PlatformColor,
|
||||||
accentColor: PlatformColor,
|
accentColor: PlatformColor,
|
||||||
backgroundColor: PlatformColor
|
backgroundColor: PlatformColor,
|
||||||
|
usesRenderedControls: Bool
|
||||||
) {
|
) {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
||||||
|
|
@ -1024,6 +1154,9 @@ enum MarkdownTextStyler {
|
||||||
.font: monospacedFont(size: 15, weight: .semibold),
|
.font: monospacedFont(size: 15, weight: .semibold),
|
||||||
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
|
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
|
||||||
], range: checkboxRange)
|
], range: checkboxRange)
|
||||||
|
if usesRenderedControls {
|
||||||
|
hideSyntax(in: textStorage, range: checkboxRange)
|
||||||
|
}
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: systemFont(size: 16, weight: .regular)
|
.font: systemFont(size: 16, weight: .regular)
|
||||||
], range: contentRange)
|
], 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