fix(renderer): stabilize render determinism
This commit is contained in:
parent
f4948c4089
commit
fcfe65050f
3 changed files with 228 additions and 10 deletions
|
|
@ -19,6 +19,10 @@ public struct RenderedTaskElement: Hashable, Sendable {
|
||||||
public var contentRange: NSRange
|
public var contentRange: NSRange
|
||||||
public var checked: Bool
|
public var checked: Bool
|
||||||
public var nestingLevel: Int
|
public var nestingLevel: Int
|
||||||
|
|
||||||
|
public var toggledMarkdownCheckbox: String {
|
||||||
|
checked ? "[ ]" : "[x]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct RenderedCodeBlockElement: Hashable, Sendable {
|
public struct RenderedCodeBlockElement: Hashable, Sendable {
|
||||||
|
|
@ -46,6 +50,145 @@ public enum RenderedDocumentElement: Hashable, Sendable {
|
||||||
case paragraph(lineIndex: Int, range: NSRange)
|
case paragraph(lineIndex: Int, range: NSRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct RenderNode: Hashable, Sendable {
|
||||||
|
public var element: RenderedDocumentElement
|
||||||
|
|
||||||
|
public init(element: RenderedDocumentElement) {
|
||||||
|
self.element = element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RenderedLine: Hashable, Sendable {
|
||||||
|
public var line: EditorLine
|
||||||
|
public var renderPlan: HybridMarkdownLineRenderPlan
|
||||||
|
public var nodes: [RenderNode]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RenderSnapshot: Hashable, Sendable {
|
||||||
|
public var lines: [String]
|
||||||
|
|
||||||
|
public init(lines: [String]) {
|
||||||
|
self.lines = lines
|
||||||
|
}
|
||||||
|
|
||||||
|
public var signature: String {
|
||||||
|
lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DocumentRenderModel: Hashable, Sendable {
|
||||||
|
public var lineCount: Int
|
||||||
|
public var lines: [RenderedLine]
|
||||||
|
public var nodes: [RenderNode]
|
||||||
|
|
||||||
|
public init(lineIndex: DocumentLineIndex, lineIndexes: [Int]? = nil) {
|
||||||
|
self.lineCount = lineIndex.lineCount
|
||||||
|
|
||||||
|
let renderer = HybridMarkdownLineRenderer()
|
||||||
|
let selectedLines = lineIndexes.map {
|
||||||
|
lineIndex.editorLines(for: $0, activeLineIndex: -1)
|
||||||
|
} ?? lineIndex.editorLines(activeLineIndex: -1)
|
||||||
|
let renderPlans = DocumentPresentationState.renderPlans(
|
||||||
|
for: selectedLines,
|
||||||
|
lineIndex: lineIndex,
|
||||||
|
activeLineIndex: -1,
|
||||||
|
renderer: renderer,
|
||||||
|
needsDocumentContext: lineIndexes != nil
|
||||||
|
)
|
||||||
|
let renderPlansByLine = Dictionary(uniqueKeysWithValues: renderPlans.map { ($0.line.index, $0) })
|
||||||
|
var renderedLines: [RenderedLine] = []
|
||||||
|
var collectedNodes: [RenderNode] = []
|
||||||
|
|
||||||
|
for line in selectedLines {
|
||||||
|
let renderPlan = renderPlansByLine[line.index] ?? renderer.renderPlan(for: line)
|
||||||
|
let nodes = DocumentPresentationState.elements(for: renderPlan, state: .rendered).map(RenderNode.init)
|
||||||
|
renderedLines.append(RenderedLine(line: line, renderPlan: renderPlan, nodes: nodes))
|
||||||
|
collectedNodes.append(contentsOf: nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lineIndexes == nil {
|
||||||
|
collectedNodes.append(contentsOf: DocumentPresentationState.codeBlockElements(from: renderPlans).map(RenderNode.init))
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lines = renderedLines
|
||||||
|
self.nodes = collectedNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
public var snapshot: RenderSnapshot {
|
||||||
|
let lineSnapshots = lines.map { renderedLine in
|
||||||
|
[
|
||||||
|
"line=\(renderedLine.line.index)",
|
||||||
|
"range=\(renderedLine.line.range.location):\(renderedLine.line.range.length)",
|
||||||
|
"kind=\(Self.describe(renderedLine.renderPlan.kind))",
|
||||||
|
"spans=\(renderedLine.renderPlan.spans.map(Self.describe).joined(separator: ","))",
|
||||||
|
"nodes=\(renderedLine.nodes.map { Self.describe($0.element) }.joined(separator: ","))"
|
||||||
|
].joined(separator: "|")
|
||||||
|
}
|
||||||
|
let treeSnapshot = "tree=" + nodes.map { Self.describe($0.element) }.joined(separator: "|")
|
||||||
|
return RenderSnapshot(lines: lineSnapshots + [treeSnapshot])
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ kind: HybridMarkdownLineKind) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .paragraph:
|
||||||
|
return "paragraph"
|
||||||
|
case .heading(let level, let markerRange, let textRange):
|
||||||
|
return "heading:\(level):\(describe(markerRange)):\(describe(textRange))"
|
||||||
|
case .blockquote(let markerRange, let contentRange):
|
||||||
|
return "blockquote:\(describe(markerRange)):\(describe(contentRange))"
|
||||||
|
case .horizontalRule(let range):
|
||||||
|
return "horizontalRule:\(describe(range))"
|
||||||
|
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
|
||||||
|
return "unorderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
||||||
|
case .orderedList(let markerRange, let contentRange, let nestingLevel):
|
||||||
|
return "orderedList:\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
||||||
|
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
||||||
|
return "task:\(describe(markerRange)):\(describe(checkboxRange)):\(describe(contentRange)):\(checked):\(nestingLevel)"
|
||||||
|
case .fencedCodeFence(let markerRange, let languageRange):
|
||||||
|
return "codeFence:\(describe(markerRange)):\(languageRange.map(describe) ?? "nil")"
|
||||||
|
case .codeBlockContent:
|
||||||
|
return "codeContent"
|
||||||
|
case .tableRow(let cellRanges, let separatorRanges, let isDivider):
|
||||||
|
return "table:\(cellRanges.map(describe).joined(separator: ",")):"
|
||||||
|
+ "\(separatorRanges.map(describe).joined(separator: ",")):\(isDivider)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ span: HybridMarkdownSpan) -> String {
|
||||||
|
"\(span.kind):\(describe(span.range))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ element: RenderedDocumentElement) -> String {
|
||||||
|
switch element {
|
||||||
|
case .heading(let heading):
|
||||||
|
return "heading:\(heading.lineIndex):\(heading.level):\(describe(heading.textRange))"
|
||||||
|
case .task(let task):
|
||||||
|
return "task:\(task.lineIndex):\(describe(task.checkboxRange)):\(task.checked)"
|
||||||
|
case .codeBlock(let codeBlock):
|
||||||
|
return "codeBlock:\(codeBlock.lineIndexes.map(String.init).joined(separator: ",")):"
|
||||||
|
+ "\(describe(codeBlock.sourceRange)):\(codeBlock.languageRange.map(describe) ?? "nil")"
|
||||||
|
case .link(let link):
|
||||||
|
return "link:\(link.lineIndex):\(describe(link.titleRange)):\(link.urlRange.map(describe) ?? "nil")"
|
||||||
|
case .inlineCode(let lineIndex, let range):
|
||||||
|
return "inlineCode:\(lineIndex):\(describe(range))"
|
||||||
|
case .blockquote(let lineIndex, let range):
|
||||||
|
return "blockquote:\(lineIndex):\(describe(range))"
|
||||||
|
case .horizontalRule(let lineIndex, let range):
|
||||||
|
return "horizontalRule:\(lineIndex):\(describe(range))"
|
||||||
|
case .listItem(let lineIndex, let markerRange, let contentRange, let nestingLevel):
|
||||||
|
return "listItem:\(lineIndex):\(describe(markerRange)):\(describe(contentRange)):\(nestingLevel)"
|
||||||
|
case .tableRow(let lineIndex, let range):
|
||||||
|
return "tableRow:\(lineIndex):\(describe(range))"
|
||||||
|
case .paragraph(let lineIndex, let range):
|
||||||
|
return "paragraph:\(lineIndex):\(describe(range))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func describe(_ range: NSRange) -> String {
|
||||||
|
"\(range.location):\(range.length)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct DocumentPresentationLine: Hashable, Sendable {
|
public struct DocumentPresentationLine: Hashable, Sendable {
|
||||||
public var line: EditorLine
|
public var line: EditorLine
|
||||||
public var state: RenderedLineState
|
public var state: RenderedLineState
|
||||||
|
|
@ -138,7 +281,7 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
).renderedTasks
|
).renderedTasks
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func renderPlans(
|
fileprivate static func renderPlans(
|
||||||
for lines: [EditorLine],
|
for lines: [EditorLine],
|
||||||
lineIndex: DocumentLineIndex,
|
lineIndex: DocumentLineIndex,
|
||||||
activeLineIndex: Int,
|
activeLineIndex: Int,
|
||||||
|
|
@ -179,7 +322,7 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
return content.hasPrefix("```") || content.hasPrefix("~~~")
|
return content.hasPrefix("```") || content.hasPrefix("~~~")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func elements(
|
fileprivate static func elements(
|
||||||
for renderPlan: HybridMarkdownLineRenderPlan,
|
for renderPlan: HybridMarkdownLineRenderPlan,
|
||||||
state: RenderedLineState
|
state: RenderedLineState
|
||||||
) -> [RenderedDocumentElement] {
|
) -> [RenderedDocumentElement] {
|
||||||
|
|
@ -247,7 +390,7 @@ public struct DocumentPresentationState: Hashable, Sendable {
|
||||||
return elements
|
return elements
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] {
|
fileprivate static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] {
|
||||||
var elements: [RenderedDocumentElement] = []
|
var elements: [RenderedDocumentElement] = []
|
||||||
var openLineIndexes: [Int] = []
|
var openLineIndexes: [Int] = []
|
||||||
var openRange: NSRange?
|
var openRange: NSRange?
|
||||||
|
|
|
||||||
|
|
@ -465,18 +465,34 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
|
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
|
||||||
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
|
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
|
||||||
|
|
||||||
let replacement = task.checked ? "[ ]" : "[x]"
|
let preservedSelection = textView.selectedRange()
|
||||||
pendingEdit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement)
|
let wasFirstResponder = textView.window?.firstResponder === textView
|
||||||
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
|
let replacement = task.toggledMarkdownCheckbox
|
||||||
|
let edit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement)
|
||||||
|
let previousPendingEdit = pendingEdit
|
||||||
pendingEdit = nil
|
pendingEdit = nil
|
||||||
|
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
|
||||||
|
pendingEdit = previousPendingEdit
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
performProgrammaticUpdate {
|
||||||
textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement)
|
textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement)
|
||||||
textView.setSelectedRange(NSRange(location: task.checkboxRange.upperBound, length: 0))
|
textView.setSelectedRange(preservedSelection)
|
||||||
textView.didChangeText()
|
textView.didChangeText()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLineIndex.replace(edit, updatedSource: textView.string)
|
||||||
|
let selection = EditorSelection(range: preservedSelection)
|
||||||
|
parent.onTextEdit(textView.string, edit, selection)
|
||||||
|
parent.selection = selection
|
||||||
|
pendingEdit = previousPendingEdit
|
||||||
|
applyHybridAttributes(to: textView)
|
||||||
|
|
||||||
|
if wasFirstResponder {
|
||||||
textView.window?.makeFirstResponder(textView)
|
textView.window?.makeFirstResponder(textView)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? {
|
private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? {
|
||||||
guard let layoutManager = textView.layoutManager,
|
guard let layoutManager = textView.layoutManager,
|
||||||
|
|
@ -542,10 +558,15 @@ private final class ChecklistOverlayButton: NSButton {
|
||||||
title = ""
|
title = ""
|
||||||
isBordered = false
|
isBordered = false
|
||||||
imagePosition = .imageOnly
|
imagePosition = .imageOnly
|
||||||
|
cell?.refusesFirstResponder = true
|
||||||
target = self
|
target = self
|
||||||
action = #selector(toggleCheckbox)
|
action = #selector(toggleCheckbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override var acceptsFirstResponder: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
nil
|
nil
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,60 @@ final class DocumentPresentationStateTests: XCTestCase {
|
||||||
XCTAssertEqual(first, second)
|
XCTAssertEqual(first, second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testDocumentRenderModelIsIndependentOfActiveLinePresentation() {
|
||||||
|
let source = "# Heading\nThis has **bold** and `code`.\n* [ ] Todo"
|
||||||
|
let lineIndex = DocumentLineIndex(source: source)
|
||||||
|
|
||||||
|
let renderModel = DocumentRenderModel(lineIndex: lineIndex)
|
||||||
|
let firstPresentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 0)
|
||||||
|
let secondPresentation = DocumentPresentationState(lineIndex: lineIndex, activeLineIndex: 2)
|
||||||
|
|
||||||
|
XCTAssertEqual(renderModel.snapshot, DocumentRenderModel(lineIndex: lineIndex).snapshot)
|
||||||
|
XCTAssertNotEqual(firstPresentation, secondPresentation)
|
||||||
|
XCTAssertTrue(renderModel.nodes.contains {
|
||||||
|
guard case .heading(let heading) = $0.element else { return false }
|
||||||
|
return heading.lineIndex == 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialRenderModelContainsHeadingsWithoutInteraction() {
|
||||||
|
let source = "# Title\n\n## Section\nParagraph"
|
||||||
|
let renderModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
||||||
|
|
||||||
|
let headingLevels = renderModel.nodes.compactMap { node -> Int? in
|
||||||
|
guard case .heading(let heading) = node.element else { return nil }
|
||||||
|
return heading.level
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(headingLevels, [1, 2])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRenderSnapshotChangesOnlyWhenMarkdownChanges() {
|
||||||
|
let source = "# Heading\n* [ ] Move with arrow keys."
|
||||||
|
let originalModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
||||||
|
let repeatedModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: source))
|
||||||
|
let toggledSource = (source as NSString).replacingCharacters(
|
||||||
|
in: NSRange(location: (source as NSString).range(of: "[ ]").location, length: 3),
|
||||||
|
with: "[x]"
|
||||||
|
)
|
||||||
|
let toggledModel = DocumentRenderModel(lineIndex: DocumentLineIndex(source: toggledSource))
|
||||||
|
|
||||||
|
XCTAssertEqual(originalModel.snapshot, repeatedModel.snapshot)
|
||||||
|
XCTAssertNotEqual(originalModel.snapshot, toggledModel.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRenderedTaskToggleReplacementDoesNotMoveSourceRanges() {
|
||||||
|
let source = "Intro\n* [ ] Move with arrow keys.\nOutro"
|
||||||
|
let lineIndex = DocumentLineIndex(source: source)
|
||||||
|
let task = DocumentRenderModel(lineIndex: lineIndex).nodes.compactMap { node -> RenderedTaskElement? in
|
||||||
|
guard case .task(let task) = node.element else { return nil }
|
||||||
|
return task
|
||||||
|
}.first
|
||||||
|
|
||||||
|
XCTAssertEqual(task?.toggledMarkdownCheckbox, "[x]")
|
||||||
|
XCTAssertEqual(task?.checkboxRange.length, ((task?.toggledMarkdownCheckbox ?? "") as NSString).length)
|
||||||
|
}
|
||||||
|
|
||||||
func testRenderedElementsAreSemantic() {
|
func testRenderedElementsAreSemantic() {
|
||||||
let source = "# Heading\n* [x] Done\nSee [Docs](https://example.com)"
|
let source = "# Heading\n* [x] Done\nSee [Docs](https://example.com)"
|
||||||
let lineIndex = DocumentLineIndex(source: source)
|
let lineIndex = DocumentLineIndex(source: source)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue