From 0dd8351847792102a173f141b86ac5ada53bb9fb Mon Sep 17 00:00:00 2001 From: Feror Date: Sun, 31 May 2026 23:01:11 +0200 Subject: [PATCH] refactor(renderer): formalize rendered lifecycle --- .../DocumentPresentationState.swift | 304 ++++++++++++++++++ .../SaplingEditor/HybridMarkdownEditor.swift | 249 ++++++++++---- .../DocumentPresentationStateTests.swift | 74 +++++ 3 files changed, 569 insertions(+), 58 deletions(-) create mode 100644 Sources/SaplingEditor/DocumentPresentationState.swift create mode 100644 Tests/SaplingEditorTests/DocumentPresentationStateTests.swift diff --git a/Sources/SaplingEditor/DocumentPresentationState.swift b/Sources/SaplingEditor/DocumentPresentationState.swift new file mode 100644 index 0000000..2d0baa6 --- /dev/null +++ b/Sources/SaplingEditor/DocumentPresentationState.swift @@ -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 + } +} diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index d508070..45d1324 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -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.. 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) diff --git a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift new file mode 100644 index 0000000..e1a49b4 --- /dev/null +++ b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift @@ -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) + } +}