fix(ui): stabilize presentation determinism

This commit is contained in:
Feror 2026-06-01 09:27:22 +02:00
parent df1e2ad8f4
commit 4f85a76033
3 changed files with 390 additions and 60 deletions

View file

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

View file

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

View file

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