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.isHorizontallyResizable = false
|
||||||
|
|
||||||
textView.delegate = context.coordinator
|
textView.delegate = context.coordinator
|
||||||
|
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
|
||||||
|
coordinator?.applyHybridAttributes(to: textView)
|
||||||
|
}
|
||||||
textView.string = text
|
textView.string = text
|
||||||
textView.isRichText = false
|
textView.isRichText = false
|
||||||
textView.isEditable = true
|
textView.isEditable = true
|
||||||
|
|
@ -227,7 +230,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
scrollView.editorTextView = textView
|
scrollView.editorTextView = textView
|
||||||
scrollView.updateEditorInsets()
|
scrollView.updateEditorInsets()
|
||||||
context.coordinator.applyHybridAttributes(to: textView)
|
context.coordinator.applyHybridAttributes(to: textView)
|
||||||
context.coordinator.requestInitialFocus(for: textView)
|
|
||||||
return scrollView
|
return scrollView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +252,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
context.coordinator.applyHybridAttributes(to: textView)
|
context.coordinator.applyHybridAttributes(to: textView)
|
||||||
context.coordinator.requestInitialFocus(for: textView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject, NSTextViewDelegate {
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
||||||
|
|
@ -260,7 +261,6 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var didFocusTextView = false
|
|
||||||
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
|
|
@ -308,7 +308,7 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: NSTextView) {
|
func applyHybridAttributes(to textView: NSTextView) {
|
||||||
guard let textStorage = textView.textStorage else { return }
|
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)
|
let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex)
|
||||||
guard invalidationPlan.requiresStyling else { return }
|
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) {
|
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
||||||
guard textView.selectedRange() != range else { return }
|
guard textView.selectedRange() != range else { return }
|
||||||
performProgrammaticUpdate {
|
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) {
|
func performProgrammaticUpdate(_ updates: () -> Void) {
|
||||||
programmaticUpdateDepth += 1
|
programmaticUpdateDepth += 1
|
||||||
defer {
|
defer {
|
||||||
|
|
@ -543,9 +530,27 @@ private struct NativeMarkdownTextView: NSViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class EditorTextView: NSTextView {
|
private final class EditorTextView: NSTextView {
|
||||||
|
var onFocusStateChange: ((NSTextView) -> Void)?
|
||||||
|
|
||||||
override var acceptsFirstResponder: Bool {
|
override var acceptsFirstResponder: Bool {
|
||||||
true
|
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 {
|
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.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
|
||||||
textView.backgroundColor = .systemBackground
|
textView.backgroundColor = .systemBackground
|
||||||
context.coordinator.applyHybridAttributes(to: textView)
|
context.coordinator.applyHybridAttributes(to: textView)
|
||||||
context.coordinator.requestInitialFocus(for: textView)
|
|
||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -636,7 +640,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
context.coordinator.invalidateStylingCache()
|
context.coordinator.invalidateStylingCache()
|
||||||
}
|
}
|
||||||
context.coordinator.applyHybridAttributes(to: textView)
|
context.coordinator.applyHybridAttributes(to: textView)
|
||||||
context.coordinator.requestInitialFocus(for: textView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject, UITextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
|
|
@ -646,8 +649,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
private var lastStyledText: String?
|
private var lastStyledText: String?
|
||||||
private var lastStyledActiveLineIndex: Int?
|
private var lastStyledActiveLineIndex: Int?
|
||||||
private var pendingEdit: DocumentLineIndexEdit?
|
private var pendingEdit: DocumentLineIndexEdit?
|
||||||
private var didFocusTextView = false
|
|
||||||
|
|
||||||
init(_ parent: NativeMarkdownTextView) {
|
init(_ parent: NativeMarkdownTextView) {
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.currentLineIndex = parent.lineIndex
|
self.currentLineIndex = parent.lineIndex
|
||||||
|
|
@ -686,7 +687,9 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHybridAttributes(to textView: UITextView) {
|
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)
|
let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex)
|
||||||
guard invalidationPlan.requiresStyling else { return }
|
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) {
|
func performProgrammaticUpdate(_ updates: () -> Void) {
|
||||||
programmaticUpdateDepth += 1
|
programmaticUpdateDepth += 1
|
||||||
defer {
|
defer {
|
||||||
|
|
@ -737,20 +748,6 @@ private struct NativeMarkdownTextView: UIViewRepresentable {
|
||||||
updates()
|
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() {
|
func invalidateStylingCache() {
|
||||||
lastStyledText = nil
|
lastStyledText = nil
|
||||||
lastStyledActiveLineIndex = nil
|
lastStyledActiveLineIndex = nil
|
||||||
|
|
@ -837,7 +834,8 @@ enum MarkdownTextStyler {
|
||||||
var styledLineIndexes: [Int] = []
|
var styledLineIndexes: [Int] = []
|
||||||
for presentationLine in presentationState.lines {
|
for presentationLine in presentationState.lines {
|
||||||
let line = presentationLine.line
|
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
|
styledLineCount += 1
|
||||||
styledLineIndexes.append(line.index)
|
styledLineIndexes.append(line.index)
|
||||||
|
|
||||||
|
|
@ -846,12 +844,13 @@ enum MarkdownTextStyler {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.backgroundColor: activeLineBackgroundColor,
|
.backgroundColor: activeLineBackgroundColor,
|
||||||
.font: monospacedFont(size: 15, weight: .regular)
|
.font: monospacedFont(size: 15, weight: .regular)
|
||||||
], range: line.range)
|
], range: paragraphRange)
|
||||||
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
||||||
case .rendered:
|
case .rendered:
|
||||||
styleRenderedLine(
|
styleRenderedLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
line: line,
|
line: line,
|
||||||
|
paragraphRange: paragraphRange,
|
||||||
renderPlan: presentationLine.renderPlan,
|
renderPlan: presentationLine.renderPlan,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
|
@ -873,16 +872,33 @@ enum MarkdownTextStyler {
|
||||||
|
|
||||||
private static func resetAttributes(
|
private static func resetAttributes(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
line: EditorLine,
|
range: NSRange,
|
||||||
textColor: PlatformColor
|
textColor: PlatformColor
|
||||||
) {
|
) {
|
||||||
guard line.range.length > 0 else { return }
|
guard range.length > 0 else { return }
|
||||||
textStorage.setAttributes(baseAttributes(textColor: textColor), range: line.range)
|
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(
|
private static func styleRenderedLine(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
line: EditorLine,
|
line: EditorLine,
|
||||||
|
paragraphRange: NSRange,
|
||||||
renderPlan: HybridMarkdownLineRenderPlan,
|
renderPlan: HybridMarkdownLineRenderPlan,
|
||||||
textColor: PlatformColor,
|
textColor: PlatformColor,
|
||||||
backgroundColor: PlatformColor,
|
backgroundColor: PlatformColor,
|
||||||
|
|
@ -899,8 +915,10 @@ enum MarkdownTextStyler {
|
||||||
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
|
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
|
||||||
)
|
)
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: systemFont(size: headingFontSize(level: level), weight: .semibold),
|
|
||||||
.paragraphStyle: headingParagraphStyle(level: level)
|
.paragraphStyle: headingParagraphStyle(level: level)
|
||||||
|
], range: paragraphRange)
|
||||||
|
textStorage.addAttributes([
|
||||||
|
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
|
||||||
], range: textRange)
|
], range: textRange)
|
||||||
case .blockquote(let markerRange, let contentRange):
|
case .blockquote(let markerRange, let contentRange):
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
|
|
@ -911,7 +929,7 @@ enum MarkdownTextStyler {
|
||||||
.foregroundColor: textColor,
|
.foregroundColor: textColor,
|
||||||
.backgroundColor: accentColor.withAlphaComponent(0.08),
|
.backgroundColor: accentColor.withAlphaComponent(0.08),
|
||||||
.paragraphStyle: blockquoteParagraphStyle()
|
.paragraphStyle: blockquoteParagraphStyle()
|
||||||
], range: line.range)
|
], range: paragraphRange)
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: italicSystemFont(size: 16)
|
.font: italicSystemFont(size: 16)
|
||||||
], range: contentRange)
|
], range: contentRange)
|
||||||
|
|
@ -924,7 +942,7 @@ enum MarkdownTextStyler {
|
||||||
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
|
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
|
||||||
styleListLine(
|
styleListLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
lineRange: line.range,
|
paragraphRange: paragraphRange,
|
||||||
markerRange: markerRange,
|
markerRange: markerRange,
|
||||||
contentRange: contentRange,
|
contentRange: contentRange,
|
||||||
nestingLevel: nestingLevel,
|
nestingLevel: nestingLevel,
|
||||||
|
|
@ -933,7 +951,7 @@ enum MarkdownTextStyler {
|
||||||
case .orderedList(let markerRange, let contentRange, let nestingLevel):
|
case .orderedList(let markerRange, let contentRange, let nestingLevel):
|
||||||
styleListLine(
|
styleListLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
lineRange: line.range,
|
paragraphRange: paragraphRange,
|
||||||
markerRange: markerRange,
|
markerRange: markerRange,
|
||||||
contentRange: contentRange,
|
contentRange: contentRange,
|
||||||
nestingLevel: nestingLevel,
|
nestingLevel: nestingLevel,
|
||||||
|
|
@ -942,7 +960,7 @@ enum MarkdownTextStyler {
|
||||||
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
|
||||||
styleTaskListLine(
|
styleTaskListLine(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
lineRange: line.range,
|
paragraphRange: paragraphRange,
|
||||||
markerRange: markerRange,
|
markerRange: markerRange,
|
||||||
checkboxRange: checkboxRange,
|
checkboxRange: checkboxRange,
|
||||||
contentRange: contentRange,
|
contentRange: contentRange,
|
||||||
|
|
@ -960,7 +978,7 @@ enum MarkdownTextStyler {
|
||||||
], range: contentRange)
|
], range: contentRange)
|
||||||
}
|
}
|
||||||
case .fencedCodeFence(let markerRange, let languageRange):
|
case .fencedCodeFence(let markerRange, let languageRange):
|
||||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
|
||||||
if let languageRange {
|
if let languageRange {
|
||||||
hideSyntax(
|
hideSyntax(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
|
|
@ -974,13 +992,13 @@ enum MarkdownTextStyler {
|
||||||
hideSyntax(in: textStorage, range: line.range)
|
hideSyntax(in: textStorage, range: line.range)
|
||||||
}
|
}
|
||||||
case .codeBlockContent:
|
case .codeBlockContent:
|
||||||
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: line.range)
|
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
|
||||||
case .tableRow(_, let separatorRanges, let isDivider):
|
case .tableRow(_, let separatorRanges, let isDivider):
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: monospacedFont(size: 15, weight: .regular),
|
.font: monospacedFont(size: 15, weight: .regular),
|
||||||
.backgroundColor: accentColor.withAlphaComponent(0.06),
|
.backgroundColor: accentColor.withAlphaComponent(0.06),
|
||||||
.paragraphStyle: tableParagraphStyle()
|
.paragraphStyle: tableParagraphStyle()
|
||||||
], range: line.range)
|
], range: paragraphRange)
|
||||||
for separatorRange in separatorRanges {
|
for separatorRange in separatorRanges {
|
||||||
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
|
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
|
||||||
}
|
}
|
||||||
|
|
@ -988,7 +1006,7 @@ enum MarkdownTextStyler {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.foregroundColor: secondaryTextColor,
|
.foregroundColor: secondaryTextColor,
|
||||||
.font: monospacedFont(size: 15, weight: .semibold)
|
.font: monospacedFont(size: 15, weight: .semibold)
|
||||||
], range: line.range)
|
], range: paragraphRange)
|
||||||
}
|
}
|
||||||
case .paragraph:
|
case .paragraph:
|
||||||
break
|
break
|
||||||
|
|
@ -1132,7 +1150,7 @@ enum MarkdownTextStyler {
|
||||||
|
|
||||||
private static func styleListLine(
|
private static func styleListLine(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
lineRange: NSRange,
|
paragraphRange: NSRange,
|
||||||
markerRange: NSRange,
|
markerRange: NSRange,
|
||||||
contentRange: NSRange,
|
contentRange: NSRange,
|
||||||
nestingLevel: Int,
|
nestingLevel: Int,
|
||||||
|
|
@ -1140,7 +1158,7 @@ enum MarkdownTextStyler {
|
||||||
) {
|
) {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
||||||
], range: lineRange)
|
], range: paragraphRange)
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.foregroundColor: secondaryTextColor,
|
.foregroundColor: secondaryTextColor,
|
||||||
.font: monospacedFont(size: 15, weight: .semibold)
|
.font: monospacedFont(size: 15, weight: .semibold)
|
||||||
|
|
@ -1152,7 +1170,7 @@ enum MarkdownTextStyler {
|
||||||
|
|
||||||
private static func styleTaskListLine(
|
private static func styleTaskListLine(
|
||||||
in textStorage: NSTextStorage,
|
in textStorage: NSTextStorage,
|
||||||
lineRange: NSRange,
|
paragraphRange: NSRange,
|
||||||
markerRange: NSRange,
|
markerRange: NSRange,
|
||||||
checkboxRange: NSRange,
|
checkboxRange: NSRange,
|
||||||
contentRange: NSRange,
|
contentRange: NSRange,
|
||||||
|
|
@ -1165,7 +1183,7 @@ enum MarkdownTextStyler {
|
||||||
) {
|
) {
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
||||||
], range: lineRange)
|
], range: paragraphRange)
|
||||||
hideSyntax(
|
hideSyntax(
|
||||||
in: textStorage,
|
in: textStorage,
|
||||||
range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location)
|
range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location)
|
||||||
|
|
@ -1176,7 +1194,7 @@ enum MarkdownTextStyler {
|
||||||
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
|
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
|
||||||
], range: checkboxRange)
|
], range: checkboxRange)
|
||||||
if usesRenderedControls {
|
if usesRenderedControls {
|
||||||
hideSyntax(in: textStorage, range: checkboxRange)
|
hideSyntaxPreservingLayout(in: textStorage, range: checkboxRange, backgroundColor: backgroundColor)
|
||||||
}
|
}
|
||||||
textStorage.addAttributes([
|
textStorage.addAttributes([
|
||||||
.font: systemFont(size: 16, weight: .regular)
|
.font: systemFont(size: 16, weight: .regular)
|
||||||
|
|
@ -1191,6 +1209,19 @@ enum MarkdownTextStyler {
|
||||||
], range: range)
|
], 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)
|
#if os(macOS)
|
||||||
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
|
||||||
NSFont.systemFont(ofSize: size, weight: weight)
|
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