From 4f85a760334118ac124ccf5f5d46f74e563ef0c3 Mon Sep 17 00:00:00 2001 From: Feror Date: Mon, 1 Jun 2026 09:27:22 +0200 Subject: [PATCH] fix(ui): stabilize presentation determinism --- .../SaplingEditor/HybridMarkdownEditor.swift | 151 +++++++++------ .../MarkdownPresentationSnapshot.swift | 179 ++++++++++++++++++ .../MarkdownPresentationSnapshotTests.swift | 120 ++++++++++++ 3 files changed, 390 insertions(+), 60 deletions(-) create mode 100644 Sources/SaplingEditor/MarkdownPresentationSnapshot.swift create mode 100644 Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift diff --git a/Sources/SaplingEditor/HybridMarkdownEditor.swift b/Sources/SaplingEditor/HybridMarkdownEditor.swift index f869445..a51f5d3 100644 --- a/Sources/SaplingEditor/HybridMarkdownEditor.swift +++ b/Sources/SaplingEditor/HybridMarkdownEditor.swift @@ -204,6 +204,9 @@ private struct NativeMarkdownTextView: NSViewRepresentable { textView.isHorizontallyResizable = false textView.delegate = context.coordinator + textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in + coordinator?.applyHybridAttributes(to: textView) + } textView.string = text textView.isRichText = false textView.isEditable = true @@ -227,7 +230,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable { scrollView.editorTextView = textView scrollView.updateEditorInsets() context.coordinator.applyHybridAttributes(to: textView) - context.coordinator.requestInitialFocus(for: textView) return scrollView } @@ -250,7 +252,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } context.coordinator.applyHybridAttributes(to: textView) - context.coordinator.requestInitialFocus(for: textView) } final class Coordinator: NSObject, NSTextViewDelegate { @@ -260,7 +261,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable { private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? private var pendingEdit: DocumentLineIndexEdit? - private var didFocusTextView = false private var checklistButtons: [Int: ChecklistOverlayButton] = [:] init(_ parent: NativeMarkdownTextView) { @@ -308,7 +308,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable { func applyHybridAttributes(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } - let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange().location) + let activeLineIndex = presentationActiveLineIndex(in: textView) let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex) guard invalidationPlan.requiresStyling else { return } @@ -357,6 +357,11 @@ private struct NativeMarkdownTextView: NSViewRepresentable { )) } + private func presentationActiveLineIndex(in textView: NSTextView) -> Int { + guard textView.window?.firstResponder === textView else { return -1 } + return currentLineIndex.lineIndex(containing: textView.selectedRange().location) + } + func setSelection(_ range: NSRange, in textView: NSTextView) { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { @@ -365,24 +370,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } } - func requestInitialFocus(for textView: NSTextView) { - guard !didFocusTextView else { return } - - DispatchQueue.main.async { [weak self, weak textView] in - guard let self, - let textView, - let window = textView.window, - !self.didFocusTextView - else { return } - - if window.firstResponder !== textView { - window.makeFirstResponder(textView) - } - - self.didFocusTextView = window.firstResponder === textView - } - } - func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { @@ -543,9 +530,27 @@ private struct NativeMarkdownTextView: NSViewRepresentable { } private final class EditorTextView: NSTextView { + var onFocusStateChange: ((NSTextView) -> Void)? + override var acceptsFirstResponder: Bool { true } + + override func becomeFirstResponder() -> Bool { + let becameFirstResponder = super.becomeFirstResponder() + if becameFirstResponder { + onFocusStateChange?(self) + } + return becameFirstResponder + } + + override func resignFirstResponder() -> Bool { + let resignedFirstResponder = super.resignFirstResponder() + if resignedFirstResponder { + onFocusStateChange?(self) + } + return resignedFirstResponder + } } private final class ChecklistOverlayButton: NSButton { @@ -622,7 +627,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable { textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24) textView.backgroundColor = .systemBackground context.coordinator.applyHybridAttributes(to: textView) - context.coordinator.requestInitialFocus(for: textView) return textView } @@ -636,7 +640,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable { context.coordinator.invalidateStylingCache() } context.coordinator.applyHybridAttributes(to: textView) - context.coordinator.requestInitialFocus(for: textView) } final class Coordinator: NSObject, UITextViewDelegate { @@ -646,8 +649,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable { private var lastStyledText: String? private var lastStyledActiveLineIndex: Int? private var pendingEdit: DocumentLineIndexEdit? - private var didFocusTextView = false - init(_ parent: NativeMarkdownTextView) { self.parent = parent self.currentLineIndex = parent.lineIndex @@ -686,7 +687,9 @@ private struct NativeMarkdownTextView: UIViewRepresentable { } func applyHybridAttributes(to textView: UITextView) { - let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange.location) + let activeLineIndex = textView.isFirstResponder + ? currentLineIndex.lineIndex(containing: textView.selectedRange.location) + : -1 let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex) guard invalidationPlan.requiresStyling else { return } @@ -729,6 +732,14 @@ private struct NativeMarkdownTextView: UIViewRepresentable { )) } + func textViewDidBeginEditing(_ textView: UITextView) { + applyHybridAttributes(to: textView) + } + + func textViewDidEndEditing(_ textView: UITextView) { + applyHybridAttributes(to: textView) + } + func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { @@ -737,20 +748,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable { updates() } - func requestInitialFocus(for textView: UITextView) { - guard !didFocusTextView else { return } - - DispatchQueue.main.async { [weak self, weak textView] in - guard let self, - let textView, - textView.window != nil, - !self.didFocusTextView - else { return } - - self.didFocusTextView = textView.becomeFirstResponder() - } - } - func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil @@ -837,7 +834,8 @@ enum MarkdownTextStyler { var styledLineIndexes: [Int] = [] for presentationLine in presentationState.lines { let line = presentationLine.line - resetAttributes(in: textStorage, line: line, textColor: textColor) + let paragraphRange = presentationRange(for: line, in: lineIndex, textLength: textStorage.length) + resetAttributes(in: textStorage, range: paragraphRange, textColor: textColor) styledLineCount += 1 styledLineIndexes.append(line.index) @@ -846,12 +844,13 @@ enum MarkdownTextStyler { textStorage.addAttributes([ .backgroundColor: activeLineBackgroundColor, .font: monospacedFont(size: 15, weight: .regular) - ], range: line.range) + ], range: paragraphRange) styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor) case .rendered: styleRenderedLine( in: textStorage, line: line, + paragraphRange: paragraphRange, renderPlan: presentationLine.renderPlan, textColor: textColor, backgroundColor: backgroundColor, @@ -873,16 +872,33 @@ enum MarkdownTextStyler { private static func resetAttributes( in textStorage: NSTextStorage, - line: EditorLine, + range: NSRange, textColor: PlatformColor ) { - guard line.range.length > 0 else { return } - textStorage.setAttributes(baseAttributes(textColor: textColor), range: line.range) + guard range.length > 0 else { return } + textStorage.setAttributes(baseAttributes(textColor: textColor), range: range) + } + + private static func presentationRange( + for line: EditorLine, + in lineIndex: DocumentLineIndex, + textLength: Int + ) -> NSRange { + guard let boundary = lineIndex.boundary(at: line.index) else { + return line.range + } + + let upperBound = min(boundary.nextLineLocation, textLength) + return NSRange( + location: boundary.contentRange.location, + length: max(0, upperBound - boundary.contentRange.location) + ) } private static func styleRenderedLine( in textStorage: NSTextStorage, line: EditorLine, + paragraphRange: NSRange, renderPlan: HybridMarkdownLineRenderPlan, textColor: PlatformColor, backgroundColor: PlatformColor, @@ -899,8 +915,10 @@ enum MarkdownTextStyler { range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location) ) textStorage.addAttributes([ - .font: systemFont(size: headingFontSize(level: level), weight: .semibold), .paragraphStyle: headingParagraphStyle(level: level) + ], range: paragraphRange) + textStorage.addAttributes([ + .font: systemFont(size: headingFontSize(level: level), weight: .semibold) ], range: textRange) case .blockquote(let markerRange, let contentRange): textStorage.addAttributes([ @@ -911,7 +929,7 @@ enum MarkdownTextStyler { .foregroundColor: textColor, .backgroundColor: accentColor.withAlphaComponent(0.08), .paragraphStyle: blockquoteParagraphStyle() - ], range: line.range) + ], range: paragraphRange) textStorage.addAttributes([ .font: italicSystemFont(size: 16) ], range: contentRange) @@ -924,7 +942,7 @@ enum MarkdownTextStyler { case .unorderedList(let markerRange, let contentRange, let nestingLevel): styleListLine( in: textStorage, - lineRange: line.range, + paragraphRange: paragraphRange, markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel, @@ -933,7 +951,7 @@ enum MarkdownTextStyler { case .orderedList(let markerRange, let contentRange, let nestingLevel): styleListLine( in: textStorage, - lineRange: line.range, + paragraphRange: paragraphRange, markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel, @@ -942,7 +960,7 @@ enum MarkdownTextStyler { case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel): styleTaskListLine( in: textStorage, - lineRange: line.range, + paragraphRange: paragraphRange, markerRange: markerRange, checkboxRange: checkboxRange, contentRange: contentRange, @@ -960,7 +978,7 @@ enum MarkdownTextStyler { ], range: contentRange) } case .fencedCodeFence(let markerRange, let languageRange): - textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) + textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange) if let languageRange { hideSyntax( in: textStorage, @@ -974,13 +992,13 @@ enum MarkdownTextStyler { hideSyntax(in: textStorage, range: line.range) } case .codeBlockContent: - textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range) + textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange) case .tableRow(_, let separatorRanges, let isDivider): textStorage.addAttributes([ .font: monospacedFont(size: 15, weight: .regular), .backgroundColor: accentColor.withAlphaComponent(0.06), .paragraphStyle: tableParagraphStyle() - ], range: line.range) + ], range: paragraphRange) for separatorRange in separatorRanges { textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange) } @@ -988,7 +1006,7 @@ enum MarkdownTextStyler { textStorage.addAttributes([ .foregroundColor: secondaryTextColor, .font: monospacedFont(size: 15, weight: .semibold) - ], range: line.range) + ], range: paragraphRange) } case .paragraph: break @@ -1132,7 +1150,7 @@ enum MarkdownTextStyler { private static func styleListLine( in textStorage: NSTextStorage, - lineRange: NSRange, + paragraphRange: NSRange, markerRange: NSRange, contentRange: NSRange, nestingLevel: Int, @@ -1140,7 +1158,7 @@ enum MarkdownTextStyler { ) { textStorage.addAttributes([ .paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel) - ], range: lineRange) + ], range: paragraphRange) textStorage.addAttributes([ .foregroundColor: secondaryTextColor, .font: monospacedFont(size: 15, weight: .semibold) @@ -1152,7 +1170,7 @@ enum MarkdownTextStyler { private static func styleTaskListLine( in textStorage: NSTextStorage, - lineRange: NSRange, + paragraphRange: NSRange, markerRange: NSRange, checkboxRange: NSRange, contentRange: NSRange, @@ -1165,7 +1183,7 @@ enum MarkdownTextStyler { ) { textStorage.addAttributes([ .paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel) - ], range: lineRange) + ], range: paragraphRange) hideSyntax( in: textStorage, range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location) @@ -1176,7 +1194,7 @@ enum MarkdownTextStyler { .backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor ], range: checkboxRange) if usesRenderedControls { - hideSyntax(in: textStorage, range: checkboxRange) + hideSyntaxPreservingLayout(in: textStorage, range: checkboxRange, backgroundColor: backgroundColor) } textStorage.addAttributes([ .font: systemFont(size: 16, weight: .regular) @@ -1191,6 +1209,19 @@ enum MarkdownTextStyler { ], range: range) } + private static func hideSyntaxPreservingLayout( + in textStorage: NSTextStorage, + range: NSRange, + backgroundColor: PlatformColor + ) { + guard range.length > 0 else { return } + textStorage.addAttributes([ + .foregroundColor: clearColor(), + .backgroundColor: backgroundColor, + .font: monospacedFont(size: 10, weight: .regular) + ], range: range) + } + #if os(macOS) private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont { NSFont.systemFont(ofSize: size, weight: weight) diff --git a/Sources/SaplingEditor/MarkdownPresentationSnapshot.swift b/Sources/SaplingEditor/MarkdownPresentationSnapshot.swift new file mode 100644 index 0000000..0cbad81 --- /dev/null +++ b/Sources/SaplingEditor/MarkdownPresentationSnapshot.swift @@ -0,0 +1,179 @@ +import Foundation + +#if os(macOS) +import AppKit + +struct MarkdownPresentationSnapshot: Hashable { + var lines: [String] + + var signature: String { + lines.joined(separator: "\n") + } + + static func make( + source: String, + activeLineIndex: Int, + containerWidth: CGFloat = 420, + usesRenderedControls: Bool = true + ) -> MarkdownPresentationSnapshot { + let storage = NSTextStorage(string: source) + let lineIndex = DocumentLineIndex(source: source) + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidator.plan( + previousText: nil, + currentLineIndex: lineIndex, + edit: nil, + previousActiveLineIndex: nil, + currentActiveLineIndex: activeLineIndex + ), + activeLineIndex: activeLineIndex, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor, + usesRenderedControls: usesRenderedControls + ) + return make(from: storage, lineIndex: lineIndex, containerWidth: containerWidth) + } + + static func make( + from storage: NSTextStorage, + lineIndex: DocumentLineIndex, + containerWidth: CGFloat = 420 + ) -> MarkdownPresentationSnapshot { + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: NSSize(width: containerWidth, height: CGFloat.greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + storage.addLayoutManager(layoutManager) + defer { + storage.removeLayoutManager(layoutManager) + } + + layoutManager.ensureLayout(for: textContainer) + + var lines: [String] = [] + for boundary in lineIndex.boundaries { + let contentRange = boundary.contentRange + let paragraphRange = NSRange( + location: boundary.contentRange.location, + length: min(boundary.nextLineLocation, storage.length) - boundary.contentRange.location + ) + let glyphRange = layoutManager.glyphRange(forCharacterRange: paragraphRange, actualCharacterRange: nil) + let fragment = glyphRange.length > 0 + ? layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) + : .zero + let paragraphStyle = storage.attribute(.paragraphStyle, at: min(paragraphRange.location, max(0, storage.length - 1)), effectiveRange: nil) as? NSParagraphStyle + let font = contentRange.length > 0 + ? storage.attribute(.font, at: contentRange.location, effectiveRange: nil) as? NSFont + : nil + + lines.append([ + "line=\(boundary.index)", + "range=\(paragraphRange.location):\(paragraphRange.length)", + "fragment=\(rounded(fragment.minX)),\(rounded(fragment.minY)),\(rounded(fragment.width)),\(rounded(fragment.height))", + "font=\(fontDescription(font))", + "paragraph=\(paragraphDescription(paragraphStyle))", + "runs=\(attributeRuns(in: storage, range: paragraphRange).joined(separator: ","))" + ].joined(separator: "|")) + } + + return MarkdownPresentationSnapshot(lines: lines) + } + + static func contentOrigin( + source: String, + text: String, + activeLineIndex: Int, + containerWidth: CGFloat = 420, + usesRenderedControls: Bool = true + ) -> CGPoint? { + let storage = NSTextStorage(string: source) + let lineIndex = DocumentLineIndex(source: source) + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidator.plan( + previousText: nil, + currentLineIndex: lineIndex, + edit: nil, + previousActiveLineIndex: nil, + currentActiveLineIndex: activeLineIndex + ), + activeLineIndex: activeLineIndex, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor, + usesRenderedControls: usesRenderedControls + ) + + let textRange = (source as NSString).range(of: text) + guard textRange.location != NSNotFound else { return nil } + + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: NSSize(width: containerWidth, height: CGFloat.greatestFiniteMagnitude)) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + storage.addLayoutManager(layoutManager) + defer { + storage.removeLayoutManager(layoutManager) + } + + layoutManager.ensureLayout(for: textContainer) + let glyphRange = layoutManager.glyphRange(forCharacterRange: NSRange(location: textRange.location, length: 1), actualCharacterRange: nil) + guard glyphRange.length > 0 else { return nil } + + let lineFragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil) + let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location) + return CGPoint(x: lineFragment.minX + glyphLocation.x, y: lineFragment.minY + glyphLocation.y) + } + + private static func attributeRuns(in storage: NSTextStorage, range: NSRange) -> [String] { + guard range.length > 0 else { return [] } + + var runs: [String] = [] + storage.enumerateAttributes(in: range) { attributes, effectiveRange, _ in + let font = attributes[.font] as? NSFont + let foreground = attributes[.foregroundColor] as? NSColor + let background = attributes[.backgroundColor] as? NSColor + let paragraph = attributes[.paragraphStyle] as? NSParagraphStyle + let hidden = foreground?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1 + runs.append([ + "\(effectiveRange.location):\(effectiveRange.length)", + fontDescription(font), + "fg=\(rounded(foreground?.alphaComponent ?? 1))", + "bg=\(rounded(background?.alphaComponent ?? 0))", + "hidden=\(hidden)", + paragraphDescription(paragraph) + ].joined(separator: "/")) + } + return runs + } + + private static func fontDescription(_ font: NSFont?) -> String { + guard let font else { return "nil" } + let isMono = font.fontDescriptor.symbolicTraits.contains(.monoSpace) + return "\(isMono ? "mono" : "system"):\(rounded(font.pointSize))" + } + + private static func paragraphDescription(_ paragraph: NSParagraphStyle?) -> String { + guard let paragraph else { return "nil" } + return [ + "ls=\(rounded(paragraph.lineSpacing))", + "psb=\(rounded(paragraph.paragraphSpacingBefore))", + "ps=\(rounded(paragraph.paragraphSpacing))", + "first=\(rounded(paragraph.firstLineHeadIndent))", + "head=\(rounded(paragraph.headIndent))" + ].joined(separator: ":") + } + + private static func rounded(_ value: CGFloat) -> String { + String(format: "%.3f", Double(value)) + } +} +#endif diff --git a/Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift b/Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift new file mode 100644 index 0000000..9c01531 --- /dev/null +++ b/Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import SaplingEditor + +#if os(macOS) +import AppKit + +final class MarkdownPresentationSnapshotTests: XCTestCase { + func testInitialUnfocusedPresentationRendersHeading() { + let source = "# Heading\nParagraph" + let snapshot = MarkdownPresentationSnapshot.make(source: source, activeLineIndex: -1) + + XCTAssertTrue(snapshot.signature.contains("system:28.000")) + XCTAssertTrue(snapshot.signature.contains("hidden=true")) + } + + func testDirtyActiveLineRoundTripMatchesFreshPresentation() { + let source = """ + # Heading + Paragraph with enough words to exercise wrapping and paragraph style. + * [ ] Move with arrow keys. + """ + let lineIndex = DocumentLineIndex(source: source) + let storage = NSTextStorage(string: source) + + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidator.plan( + previousText: nil, + currentLineIndex: lineIndex, + edit: nil, + previousActiveLineIndex: nil, + currentActiveLineIndex: -1 + ), + activeLineIndex: -1, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor, + usesRenderedControls: true + ) + + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidationPlan( + reason: .activeLineChange, + isFullRender: false, + dirtyLineIndexes: [1] + ), + activeLineIndex: 1, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor, + usesRenderedControls: true + ) + + MarkdownTextStyler.apply( + to: storage, + lineIndex: lineIndex, + invalidationPlan: EditorDirtyLineInvalidationPlan( + reason: .activeLineChange, + isFullRender: false, + dirtyLineIndexes: [1] + ), + activeLineIndex: -1, + backgroundColor: .textBackgroundColor, + activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), + textColor: .labelColor, + secondaryTextColor: .secondaryLabelColor, + accentColor: .controlAccentColor, + usesRenderedControls: true + ) + + let roundTrip = MarkdownPresentationSnapshot.make(from: storage, lineIndex: lineIndex) + let fresh = MarkdownPresentationSnapshot.make(source: source, activeLineIndex: -1) + + XCTAssertEqual(roundTrip, fresh) + } + + func testChecklistContentGeometryIsStableAcrossToggleState() throws { + let unchecked = "* [ ] Move with arrow keys." + let checked = "* [x] Move with arrow keys." + + let uncheckedOrigin = MarkdownPresentationSnapshot.contentOrigin( + source: unchecked, + text: "Move", + activeLineIndex: -1, + usesRenderedControls: true + ) + let checkedOrigin = MarkdownPresentationSnapshot.contentOrigin( + source: checked, + text: "Move", + activeLineIndex: -1, + usesRenderedControls: true + ) + + let uncheckedPoint = try XCTUnwrap(uncheckedOrigin) + let checkedPoint = try XCTUnwrap(checkedOrigin) + XCTAssertEqual(uncheckedPoint.x, checkedPoint.x, accuracy: 0.001) + XCTAssertEqual(uncheckedPoint.y, checkedPoint.y, accuracy: 0.001) + } + + func testParagraphGeometryIsStableAcrossRepeatedPresentation() { + let source = """ + Intro paragraph with enough words to wrap in a predictable container width for presentation snapshots. + + Another paragraph that should keep the same geometry when presentation is regenerated. + """ + + let first = MarkdownPresentationSnapshot.make(source: source, activeLineIndex: -1, containerWidth: 240) + let second = MarkdownPresentationSnapshot.make(source: source, activeLineIndex: -1, containerWidth: 240) + + XCTAssertEqual(first, second) + } +} +#endif