From fcfe65050fc6a7057aec78c39ecc8d05d53281fc Mon Sep 17 00:00:00 2001 From: Feror Date: Mon, 1 Jun 2026 09:13:09 +0200 Subject: [PATCH] fix(renderer): stabilize render determinism --- .../DocumentPresentationState.swift | 149 +++++++++++++++++- .../SaplingEditor/HybridMarkdownEditor.swift | 35 +++- .../DocumentPresentationStateTests.swift | 54 +++++++ 3 files changed, 228 insertions(+), 10 deletions(-) diff --git a/Sources/SaplingEditor/DocumentPresentationState.swift b/Sources/SaplingEditor/DocumentPresentationState.swift index 2d0baa6..0dd0977 100644 --- a/Sources/SaplingEditor/DocumentPresentationState.swift +++ b/Sources/SaplingEditor/DocumentPresentationState.swift @@ -19,6 +19,10 @@ public struct RenderedTaskElement: Hashable, Sendable { public var contentRange: NSRange public var checked: Bool public var nestingLevel: Int + + public var toggledMarkdownCheckbox: String { + checked ? "[ ]" : "[x]" + } } public struct RenderedCodeBlockElement: Hashable, Sendable { @@ -46,6 +50,145 @@ public enum RenderedDocumentElement: Hashable, Sendable { 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 var line: EditorLine public var state: RenderedLineState @@ -138,7 +281,7 @@ public struct DocumentPresentationState: Hashable, Sendable { ).renderedTasks } - private static func renderPlans( + fileprivate static func renderPlans( for lines: [EditorLine], lineIndex: DocumentLineIndex, activeLineIndex: Int, @@ -179,7 +322,7 @@ public struct DocumentPresentationState: Hashable, Sendable { return content.hasPrefix("```") || content.hasPrefix("~~~") } - private static func elements( + fileprivate static func elements( for renderPlan: HybridMarkdownLineRenderPlan, state: RenderedLineState ) -> [RenderedDocumentElement] { @@ -247,7 +390,7 @@ public struct DocumentPresentationState: Hashable, Sendable { return elements } - private static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] { + fileprivate static func codeBlockElements(from plans: [HybridMarkdownLineRenderPlan]) -> [RenderedDocumentElement] { var elements: [RenderedDocumentElement] = [] var openLineIndexes: [Int] = [] var openRange: NSRange? diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index 45d1324..f869445 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -465,17 +465,33 @@ private struct NativeMarkdownTextView: NSViewRepresentable { 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) + let preservedSelection = textView.selectedRange() + let wasFirstResponder = textView.window?.firstResponder === textView + let replacement = task.toggledMarkdownCheckbox + let edit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement) + let previousPendingEdit = pendingEdit + pendingEdit = nil guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else { - pendingEdit = nil + pendingEdit = previousPendingEdit return } - textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement) - textView.setSelectedRange(NSRange(location: task.checkboxRange.upperBound, length: 0)) - textView.didChangeText() - textView.window?.makeFirstResponder(textView) + performProgrammaticUpdate { + textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement) + textView.setSelectedRange(preservedSelection) + 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) + } } private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? { @@ -542,10 +558,15 @@ private final class ChecklistOverlayButton: NSButton { title = "" isBordered = false imagePosition = .imageOnly + cell?.refusesFirstResponder = true target = self action = #selector(toggleCheckbox) } + override var acceptsFirstResponder: Bool { + false + } + @available(*, unavailable) required init?(coder: NSCoder) { nil diff --git a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift index e1a49b4..2bd7582 100644 --- a/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift +++ b/Tests/SaplingEditorTests/DocumentPresentationStateTests.swift @@ -26,6 +26,60 @@ final class DocumentPresentationStateTests: XCTestCase { 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() { let source = "# Heading\n* [x] Done\nSee [Docs](https://example.com)" let lineIndex = DocumentLineIndex(source: source)