refactor(renderer): formalize rendered lifecycle

This commit is contained in:
Feror 2026-05-31 23:01:11 +02:00
parent fc21b9d1bd
commit 0dd8351847
3 changed files with 569 additions and 58 deletions

View 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
}
}

View file

@ -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)

View file

@ -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)
}
}