fix(ui): stabilize presentation determinism
This commit is contained in:
parent
df1e2ad8f4
commit
4f85a76033
3 changed files with 390 additions and 60 deletions
|
|
@ -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)
|
||||
|
|
|
|||
179
Sources/SaplingEditor/MarkdownPresentationSnapshot.swift
Normal file
179
Sources/SaplingEditor/MarkdownPresentationSnapshot.swift
Normal 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
|
||||
120
Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift
Normal file
120
Tests/SaplingEditorTests/MarkdownPresentationSnapshotTests.swift
Normal 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
|
||||
Loading…
Add table
Reference in a new issue