Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

2222 lines
87 KiB
Swift

import Foundation
import SwiftUI
import SaplingCore
import SaplingRenderer
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
@Published public private(set) var state: EditorState
public private(set) var instrumentation = EditorInstrumentationSnapshot()
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.state = EditorState(
document: EditorDocument(markdownDocument: document),
activeLineIndex: activeLineIndex
)
}
public var document: MarkdownDocument {
state.document.markdownDocument
}
public var hasUnsavedChanges: Bool {
state.hasUnsavedChanges
}
public var activeLineIndex: Int {
state.activeLineIndex
}
public var lineIndex: DocumentLineIndex {
state.lineIndex
}
public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document)
instrumentation = EditorInstrumentationSnapshot()
}
public func updateSource(_ source: String) {
guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSource(source)
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSource(_ source: String, edit: DocumentLineIndexEdit?, selection: EditorSelection? = nil) {
guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
if let edit {
state.updateSource(source, edit: edit, selection: selection)
} else {
state.updateSource(source)
if let selection {
state.updateSelection(selection)
}
}
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSelection(_ selection: EditorSelection) {
guard state.selection != selection else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSelection(selection)
instrumentation.recordSelectionChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
instrumentation.recordRenderPass(metric)
#if DEBUG
EditorDiagnostics.logRenderPass(metric)
#endif
}
public func save() throws {
try state.document.source.write(to: state.document.url, atomically: true, encoding: .utf8)
state.markSaved()
}
public static func loadDocument(at url: URL) throws -> MarkdownDocument {
let source = try String(contentsOf: url, encoding: .utf8)
let title = url.deletingPathExtension().lastPathComponent
return MarkdownDocument(url: url, title: title, content: source)
}
private func recordActiveLineChangeIfNeeded(_ previousActiveLineIndex: Int) {
if previousActiveLineIndex != state.activeLineIndex {
instrumentation.recordActiveLineChange()
}
}
}
public struct HybridMarkdownEditor: View, EditorView {
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
private let renderer: any MarkdownRendering
public init(
viewModel: HybridMarkdownEditorViewModel,
renderer: any MarkdownRendering = MarkdownRenderer()
) {
self.viewModel = viewModel
self.renderer = renderer
}
public var state: EditorState {
viewModel.state
}
public var body: some View {
VStack(spacing: 0) {
NativeMarkdownTextView(
text: Binding(
get: { viewModel.state.document.source },
set: { viewModel.updateSource($0) }
),
selection: Binding(
get: { viewModel.state.selection },
set: { viewModel.updateSelection($0) }
),
activeLineIndex: viewModel.state.activeLineIndex,
lineIndex: viewModel.state.lineIndex,
onTextEdit: viewModel.updateSource,
onRenderPass: viewModel.recordRenderPass
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
columnNumber: viewModel.state.activeColumnNumber,
lineCount: viewModel.state.lineCount,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
)
}
.background(platformTextBackground)
}
}
private struct EditorStatusBar: View {
let activeLineIndex: Int
let columnNumber: Int
let lineCount: Int
let hasUnsavedChanges: Bool
var body: some View {
HStack(spacing: 12) {
Text("Line \(activeLineIndex + 1)")
Text("Column \(columnNumber)")
Text("\(lineCount) lines")
Text(hasUnsavedChanges ? "Modified" : "Saved")
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
Spacer()
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.thinMaterial)
}
}
#if os(macOS)
private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = ComfortableEditorScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = true
scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let textView = EditorTextView(frame: scrollView.contentView.bounds, textContainer: textContainer)
textView.autoresizingMask = [.width]
textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.string = text
textView.delegate = context.coordinator
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
}
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView, interactionKind in
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
}
textView.isRichText = false
textView.isEditable = true
textView.isSelectable = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.allowsUndo = true
textView.usesFindPanel = true
textView.isContinuousSpellCheckingEnabled = true
textView.backgroundColor = .textBackgroundColor
textView.insertionPointColor = .controlAccentColor
textView.font = .systemFont(ofSize: 16, weight: .regular)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
scrollView.documentView = textView
scrollView.editorTextView = textView
scrollView.onEditorLayoutInvalidated = { [weak coordinator = context.coordinator] textView in
coordinator?.syncChecklistControlFrames(in: textView)
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
}
scrollView.updateEditorInsets()
context.coordinator.applyHybridAttributes(to: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
context.coordinator.performProgrammaticUpdate {
textView.string = text
}
context.coordinator.invalidateStylingCache()
}
let selectedRange = selection.range
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
context.coordinator.setSelection(selectedRange, in: textView, interactionKind: .programmatic)
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var lastStyledEditableRegion: EditableRegion?
private var pendingEdit: DocumentLineIndexEdit?
private var hasUserActivatedEditing = false
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
fileprivate var viewportStabilityEvents: [ViewportStabilityEvent] = []
private var lastUserInteractionKind: EditorInteractionKind = .programmatic
private var pendingSelectionInteractionKind: EditorInteractionKind?
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: NSTextView,
shouldChangeTextIn affectedCharRange: NSRange,
replacementString: String?
) -> Bool {
pendingEdit = DocumentLineIndexEdit(
range: affectedCharRange,
replacement: replacementString ?? ""
)
return true
}
func textDidChange(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
hasUserActivatedEditing = true
let selection = EditorSelection(range: textView.selectedRange())
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit, updatedSource: textView.string)
} else {
currentLineIndex = DocumentLineIndex(source: textView.string)
}
parent.onTextEdit(textView.string, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView, cause: .sourceChange)
pendingEdit = nil
}
func textViewDidChangeSelection(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
let newSelection = EditorSelection(range: textView.selectedRange())
guard parent.selection != newSelection else { return }
let interactionKind = pendingSelectionInteractionKind ?? lastUserInteractionKind
pendingSelectionInteractionKind = nil
applyHybridAttributes(to: textView, cause: .selectionChange(interactionKind))
parent.selection = newSelection
}
func applyHybridAttributes(
to textView: NSTextView,
cause: PresentationUpdateCause = .viewUpdate
) {
guard let textStorage = textView.textStorage else { return }
let editableRegion = presentationEditableRegion(in: textView)
let activeLineIndex = editableRegion.primaryLineIndex
let invalidationPlan = invalidationPlan(for: textView.string, editableRegion: editableRegion)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange()
let stabilizationDecision = viewportStabilizationDecision(
cause: cause,
invalidationPlan: invalidationPlan,
editableRegion: editableRegion,
selectedRange: selectedRange
)
let viewportAnchor = stabilizationDecision.shouldStabilize
? ViewportPresentationAnchor.capture(
in: textView,
selectedRange: selectedRange,
lineIndex: currentLineIndex
)
: nil
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreVisibleOrigin = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor,
usesRenderedControls: true,
editableRegion: editableRegion
)
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
}
if stabilizationDecision.shouldStabilize {
didRestoreVisibleOrigin = restoreViewportAnchor(viewportAnchor, in: textView)
}
lastStyledText = textView.string
lastStyledActiveLineIndex = activeLineIndex
lastStyledEditableRegion = editableRegion
recordViewportStabilityEvent(
cause: cause,
invalidationPlan: invalidationPlan,
decision: stabilizationDecision,
capturedAnchor: viewportAnchor != nil,
restored: didRestoreVisibleOrigin
)
syncChecklistControls(
in: textView,
stylingResult: stylingResult,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex
)
syncCodeBlockContainers(in: textView, editableRegion: editableRegion)
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.string.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreVisibleOrigin
))
}
private func presentationEditableRegion(in textView: NSTextView) -> EditableRegion {
guard hasUserActivatedEditing,
textView.window?.firstResponder === textView
else { return .none() }
return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex)
}
func activateEditingPresentation(
in textView: NSTextView,
interactionKind: EditorInteractionKind = .programmatic
) {
lastUserInteractionKind = interactionKind
guard !hasUserActivatedEditing else { return }
hasUserActivatedEditing = true
applyHybridAttributes(to: textView, cause: .editingActivation(interactionKind))
}
func setSelection(
_ range: NSRange,
in textView: NSTextView,
interactionKind: EditorInteractionKind = .programmatic
) {
guard textView.selectedRange() != range else { return }
performProgrammaticUpdate {
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
let shouldScroll = selectionNeedsScroll(range, in: textView)
pendingSelectionInteractionKind = interactionKind
textView.setSelectedRange(range)
if shouldScroll {
textView.scrollRangeToVisible(range)
} else if let visibleOrigin {
_ = scrollVisibleOrigin(visibleOrigin, in: textView)
}
}
}
private func selectionNeedsScroll(_ range: NSRange, in textView: NSTextView) -> Bool {
guard let point = textView.presentationAnchorPoint(at: range.location),
let scrollView = textView.enclosingScrollView
else { return true }
let visibleRect = scrollView.contentView.bounds.insetBy(dx: 0, dy: 12)
return !visibleRect.contains(point)
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
lastStyledEditableRegion = nil
hasUserActivatedEditing = false
removeChecklistControls()
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
let previousEditableRegion = lastStyledEditableRegion
let plan = EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
currentActiveLineIndex: editableRegion.primaryLineIndex
)
return plan.includingEditableRegionTransition(
from: previousEditableRegion,
to: editableRegion,
lineCount: currentLineIndex.lineCount
)
}
private func viewportStabilizationDecision(
cause: PresentationUpdateCause,
invalidationPlan: EditorDirtyLineInvalidationPlan,
editableRegion: EditableRegion,
selectedRange: NSRange
) -> ViewportStabilizationDecision {
guard invalidationPlan.reason == .activeLineChange else {
return ViewportStabilizationDecision(shouldStabilize: false, reason: "not-active-line-change")
}
guard lastStyledEditableRegion != editableRegion else {
return ViewportStabilizationDecision(shouldStabilize: false, reason: "no-editable-region-transition")
}
guard selectedRange.length == 0 else {
return ViewportStabilizationDecision(shouldStabilize: false, reason: "range-selection")
}
switch cause {
case .selectionChange(.keyboard), .editingActivation(.keyboard):
return ViewportStabilizationDecision(shouldStabilize: false, reason: "native-keyboard-navigation")
case .selectionChange(.programmatic), .editingActivation(.programmatic):
return ViewportStabilizationDecision(shouldStabilize: false, reason: "programmatic-selection")
case .selectionChange(.mouse), .editingActivation(.mouse), .focusChange:
return ViewportStabilizationDecision(shouldStabilize: true, reason: "presentation-transition")
case .selectionChange(.paste), .editingActivation(.paste), .sourceChange, .viewUpdate:
return ViewportStabilizationDecision(shouldStabilize: false, reason: "non-presentation-navigation")
}
}
private func recordViewportStabilityEvent(
cause: PresentationUpdateCause,
invalidationPlan: EditorDirtyLineInvalidationPlan,
decision: ViewportStabilizationDecision,
capturedAnchor: Bool,
restored: Bool
) {
viewportStabilityEvents.append(ViewportStabilityEvent(
cause: cause.description,
renderReason: "\(invalidationPlan.reason)",
decision: decision.reason,
capturedAnchor: capturedAnchor,
restored: restored
))
if viewportStabilityEvents.count > 200 {
viewportStabilityEvents.removeFirst(viewportStabilityEvents.count - 200)
}
}
private func syncChecklistControls(
in textView: NSTextView,
stylingResult: MarkdownTextStylingResult,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int
) {
let shouldRebuildAll = invalidationPlan.isFullRender || invalidationPlan.reason == .sourceChange
let renderedTasks = shouldRebuildAll
? DocumentPresentationState.renderedTasks(in: currentLineIndex, activeLineIndex: activeLineIndex)
: stylingResult.renderedTasks
let tasksByLine = Dictionary(uniqueKeysWithValues: renderedTasks.map { ($0.lineIndex, $0) })
if shouldRebuildAll {
let validLineIndexes = Set(tasksByLine.keys)
for lineIndex in Array(checklistButtons.keys) where !validLineIndexes.contains(lineIndex) {
removeChecklistControl(at: lineIndex)
}
} else {
for lineIndex in stylingResult.styledLineIndexes where tasksByLine[lineIndex] == nil {
removeChecklistControl(at: lineIndex)
}
}
for task in renderedTasks {
let button = checklistButtons[task.lineIndex] ?? ChecklistOverlayButton()
button.task = task
button.onToggle = { [weak self, weak textView, weak button] in
guard let task = button?.task,
let textView
else { return }
self?.toggleTask(task, in: textView)
}
button.state = task.checked ? .on : .off
button.toolTip = task.checked ? "Mark task incomplete" : "Mark task complete"
if button.superview !== textView {
textView.addSubview(button)
}
checklistButtons[task.lineIndex] = button
}
syncChecklistControlFrames(in: textView)
}
func syncChecklistControlFrames(in textView: NSTextView) {
guard !checklistButtons.isEmpty else { return }
for (lineIndex, button) in Array(checklistButtons) {
guard let task = button.task,
let frame = checklistFrame(for: task, in: textView)
else {
removeChecklistControl(at: lineIndex)
continue
}
button.frame = frame
}
}
private func syncCodeBlockContainers(in textView: NSTextView, editableRegion: EditableRegion) {
guard let textView = textView as? EditorTextView else { return }
let codeBlocks = DocumentPresentationState.renderedCodeBlocks(
in: currentLineIndex,
editableRegion: editableRegion
)
textView.codeBlockContainers = codeBlocks.map {
CodeBlockContainerPresentation(
codeBlock: $0,
languageLabel: codeBlockLanguageLabel(for: $0, in: textView.string)
)
}
}
private func codeBlockLanguageLabel(
for codeBlock: RenderedCodeBlockElement,
in source: String
) -> String {
guard let languageRange = codeBlock.languageRange,
languageRange.upperBound <= source.utf16.count
else { return "Text" }
let language = (source as NSString).substring(with: languageRange)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !language.isEmpty else { return "Text" }
return language.prefix(1).uppercased() + language.dropFirst().lowercased()
}
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
let preservedSelection = textView.selectedRange()
let wasFirstResponder = textView.window?.firstResponder === textView
let replacement = task.toggledMarkdownCheckbox
let edit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement)
let previousPendingEdit = pendingEdit
pendingEdit = nil
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
pendingEdit = previousPendingEdit
return
}
performProgrammaticUpdate {
textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement)
textView.setSelectedRange(preservedSelection)
textView.didChangeText()
}
currentLineIndex.replace(edit, updatedSource: textView.string)
let selection = EditorSelection(range: preservedSelection)
parent.onTextEdit(textView.string, edit, selection)
parent.selection = selection
pendingEdit = previousPendingEdit
invalidateStylingCache()
applyHybridAttributes(to: textView)
if wasFirstResponder {
textView.window?.makeFirstResponder(textView)
}
}
private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer,
task.checkboxRange.location < textView.string.utf16.count
else { return nil }
let characterRange = NSRange(location: task.checkboxRange.location, length: 1)
layoutManager.ensureLayout(forCharacterRange: characterRange)
let glyphRange = layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil)
guard glyphRange.length > 0 else { return nil }
let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
let origin = textView.textContainerOrigin
return NSRect(
x: origin.x + glyphRect.minX - 2,
y: origin.y + glyphRect.minY - 1,
width: 18,
height: 18
)
}
private func removeChecklistControl(at lineIndex: Int) {
checklistButtons[lineIndex]?.removeFromSuperview()
checklistButtons.removeValue(forKey: lineIndex)
}
private func removeChecklistControls() {
checklistButtons.values.forEach { $0.removeFromSuperview() }
checklistButtons.removeAll()
}
private func restoreViewportAnchor(
_ anchor: ViewportPresentationAnchor?,
in textView: NSTextView
) -> Bool {
guard let anchor,
let point = textView.presentationAnchorPoint(at: anchor.characterLocation)
else {
return false
}
return scrollVisibleOrigin(
NSPoint(
x: anchor.visibleOrigin.x,
y: point.y - anchor.viewportOffsetY
),
in: textView
)
}
private func scrollVisibleOrigin(_ origin: NSPoint, in textView: NSTextView) -> Bool {
guard let scrollView = textView.enclosingScrollView else { return false }
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
let clampedOrigin = NSPoint(
x: max(0, min(origin.x, maxX)),
y: max(0, min(origin.y, maxY))
)
scrollView.contentView.scroll(to: clampedOrigin)
scrollView.reflectScrolledClipView(scrollView.contentView)
return true
}
}
}
private enum EditorInteractionKind: String {
case mouse
case keyboard
case paste
case programmatic
}
private enum PresentationUpdateCause: CustomStringConvertible {
case editingActivation(EditorInteractionKind)
case selectionChange(EditorInteractionKind)
case focusChange
case sourceChange
case viewUpdate
var description: String {
switch self {
case .editingActivation(let kind):
return "editingActivation:\(kind.rawValue)"
case .selectionChange(let kind):
return "selectionChange:\(kind.rawValue)"
case .focusChange:
return "focusChange"
case .sourceChange:
return "sourceChange"
case .viewUpdate:
return "viewUpdate"
}
}
}
private struct ViewportStabilizationDecision {
var shouldStabilize: Bool
var reason: String
}
private struct ViewportStabilityEvent: Hashable, Sendable {
var cause: String
var renderReason: String
var decision: String
var capturedAnchor: Bool
var restored: Bool
}
private struct CodeBlockContainerPresentation: Equatable {
var codeBlock: RenderedCodeBlockElement
var languageLabel: String
}
private struct ViewportPresentationAnchor {
var characterLocation: Int
var visibleOrigin: NSPoint
var viewportOffsetY: CGFloat
static func capture(
in textView: NSTextView,
selectedRange: NSRange,
lineIndex: DocumentLineIndex
) -> ViewportPresentationAnchor? {
guard let scrollView = textView.enclosingScrollView,
textView.string.utf16.count > 0
else { return nil }
let location = presentationAnchorLocation(
for: selectedRange,
lineIndex: lineIndex,
textLength: textView.string.utf16.count
)
guard let point = textView.presentationAnchorPoint(at: location) else { return nil }
let visibleOrigin = scrollView.contentView.bounds.origin
return ViewportPresentationAnchor(
characterLocation: location,
visibleOrigin: visibleOrigin,
viewportOffsetY: point.y - visibleOrigin.y
)
}
private static func presentationAnchorLocation(
for selectedRange: NSRange,
lineIndex: DocumentLineIndex,
textLength: Int
) -> Int {
let selectedLocation = min(max(0, selectedRange.location), max(0, textLength - 1))
let lineNumber = lineIndex.lineIndex(containing: selectedLocation)
guard let line = lineIndex.editorLine(at: lineNumber, activeLineIndex: lineNumber) else {
return selectedLocation
}
let renderPlan = HybridMarkdownLineRenderer().renderPlan(for: line)
switch renderPlan.kind {
case .heading(_, _, let textRange):
return selectedLocation < textRange.location ? textRange.location : selectedLocation
case .taskList(_, _, let contentRange, _, _):
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
case .unorderedList(_, let contentRange, _),
.orderedList(_, let contentRange, _),
.blockquote(_, let contentRange):
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
default:
return selectedLocation
}
}
}
private final class EditorTextView: NSTextView {
var onFocusStateChange: ((NSTextView) -> Void)?
var onUserEditingInteraction: ((NSTextView, EditorInteractionKind) -> Void)?
var codeBlockContainers: [CodeBlockContainerPresentation] = [] {
didSet {
guard oldValue != codeBlockContainers else { return }
needsDisplay = true
}
}
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
}
override func mouseDown(with event: NSEvent) {
onUserEditingInteraction?(self, .mouse)
super.mouseDown(with: event)
}
override func keyDown(with event: NSEvent) {
onUserEditingInteraction?(self, .keyboard)
super.keyDown(with: event)
}
override func paste(_ sender: Any?) {
onUserEditingInteraction?(self, .paste)
super.paste(sender)
}
func invalidateCodeBlockContainers() {
needsDisplay = true
}
func codeBlockContainerFrame(containing lineIndex: Int) -> NSRect? {
guard let container = codeBlockContainers.first(where: { $0.codeBlock.lineIndexes.contains(lineIndex) }) else {
return nil
}
return codeBlockFrame(for: container)
}
override func drawBackground(in rect: NSRect) {
super.drawBackground(in: rect)
drawCodeBlockContainers(in: rect)
}
private func drawCodeBlockContainers(in dirtyRect: NSRect) {
for container in codeBlockContainers {
guard let frame = codeBlockFrame(for: container),
frame.intersects(dirtyRect)
else { continue }
drawCodeBlockContainer(container, in: frame)
}
}
private func drawCodeBlockContainer(
_ container: CodeBlockContainerPresentation,
in frame: NSRect
) {
let cornerRadius: CGFloat = 8
let headerHeight = min(30, max(24, frame.height * 0.32))
let headerRect = NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: headerHeight)
let bodyPath = NSBezierPath(roundedRect: frame, xRadius: cornerRadius, yRadius: cornerRadius)
NSColor.controlAccentColor.withAlphaComponent(0.075).setFill()
bodyPath.fill()
NSGraphicsContext.saveGraphicsState()
bodyPath.addClip()
NSColor.controlAccentColor.withAlphaComponent(0.12).setFill()
headerRect.fill()
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
NSBezierPath.strokeLine(
from: NSPoint(x: headerRect.minX, y: headerRect.maxY),
to: NSPoint(x: headerRect.maxX, y: headerRect.maxY)
)
NSGraphicsContext.restoreGraphicsState()
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
bodyPath.lineWidth = 1
bodyPath.stroke()
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold),
.foregroundColor: NSColor.secondaryLabelColor
]
let label = NSAttributedString(string: container.languageLabel, attributes: labelAttributes)
let labelRect = NSRect(
x: headerRect.minX + 14,
y: headerRect.midY - ceil(label.size().height / 2),
width: max(0, headerRect.width - 28),
height: label.size().height
)
label.draw(in: labelRect)
}
private func codeBlockFrame(for container: CodeBlockContainerPresentation) -> NSRect? {
guard let layoutManager,
let textContainer,
textStorage?.length ?? 0 > 0
else { return nil }
let textLength = string.utf16.count
let sourceRange = NSRange(
location: min(container.codeBlock.sourceRange.location, max(0, textLength - 1)),
length: min(
container.codeBlock.sourceRange.length,
max(0, textLength - container.codeBlock.sourceRange.location)
)
)
guard sourceRange.location < textLength else { return nil }
layoutManager.ensureLayout(forCharacterRange: sourceRange)
let firstCharacterRange = NSRange(location: sourceRange.location, length: 1)
let lastLocation = max(sourceRange.location, min(sourceRange.upperBound - 1, textLength - 1))
let lastCharacterRange = NSRange(location: lastLocation, length: 1)
let firstGlyphRange = layoutManager.glyphRange(
forCharacterRange: firstCharacterRange,
actualCharacterRange: nil
)
let lastGlyphRange = layoutManager.glyphRange(
forCharacterRange: lastCharacterRange,
actualCharacterRange: nil
)
guard firstGlyphRange.length > 0, lastGlyphRange.length > 0 else { return nil }
let firstFragment = layoutManager.lineFragmentRect(forGlyphAt: firstGlyphRange.location, effectiveRange: nil)
let lastFragment = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphRange.location, effectiveRange: nil)
let origin = textContainerOrigin
let horizontalInset: CGFloat = 4
return NSRect(
x: origin.x + horizontalInset,
y: origin.y + firstFragment.minY,
width: max(0, textContainer.containerSize.width - horizontalInset * 2),
height: max(0, lastFragment.maxY - firstFragment.minY)
)
}
}
private final class ChecklistOverlayButton: NSButton {
var task: RenderedTaskElement?
var onToggle: (() -> Void)?
init() {
super.init(frame: .zero)
setButtonType(.switch)
title = ""
isBordered = false
imagePosition = .imageOnly
cell?.refusesFirstResponder = true
target = self
action = #selector(toggleCheckbox)
}
override var acceptsFirstResponder: Bool {
false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
@objc private func toggleCheckbox() {
onToggle?()
}
}
private final class ComfortableEditorScrollView: NSScrollView {
weak var editorTextView: NSTextView?
var onEditorLayoutInvalidated: ((NSTextView) -> Void)?
private var lastLayoutSize: NSSize = .zero
override func layout() {
super.layout()
let didChangeInsets = updateEditorInsets()
guard let editorTextView else { return }
let layoutSize = contentView.bounds.size
if didChangeInsets || layoutSize != lastLayoutSize {
lastLayoutSize = layoutSize
onEditorLayoutInvalidated?(editorTextView)
}
}
@discardableResult
func updateEditorInsets() -> Bool {
guard let editorTextView else { return false }
let readableWidth: CGFloat = 760
let horizontalInset = max(36, floor((contentView.bounds.width - readableWidth) / 2))
let targetInset = NSSize(width: horizontalInset, height: 38)
if editorTextView.textContainerInset != targetInset {
editorTextView.textContainerInset = targetInset
return true
}
return false
}
}
#if DEBUG
@MainActor
public final class HybridMarkdownLiveEditorHarness {
public private(set) var text: String
public private(set) var selection: EditorSelection
public private(set) var renderPasses: [EditorRenderPassMetric] = []
private let box: StateBox
private let coordinator: NativeMarkdownTextView.Coordinator
private let window: NSWindow
private let scrollView: ComfortableEditorScrollView
private let textView: EditorTextView
public init(
source: String,
selectedRange: NSRange = NSRange(location: 0, length: 0),
initialWidth: CGFloat = 640
) {
let stateBox = StateBox(text: source, selection: EditorSelection(range: selectedRange))
self.text = source
self.selection = EditorSelection(range: selectedRange)
self.box = stateBox
let lineIndex = DocumentLineIndex(source: source)
let parent = NativeMarkdownTextView(
text: Binding(
get: { stateBox.text },
set: { stateBox.text = $0 }
),
selection: Binding(
get: { stateBox.selection },
set: { stateBox.selection = $0 }
),
activeLineIndex: lineIndex.lineIndex(containing: selectedRange.location),
lineIndex: lineIndex,
onTextEdit: { updatedText, edit, updatedSelection in
stateBox.text = updatedText
if let edit {
stateBox.lineIndex.replace(edit, updatedSource: updatedText)
} else {
stateBox.lineIndex = DocumentLineIndex(source: updatedText)
}
if let updatedSelection {
stateBox.selection = updatedSelection
}
},
onRenderPass: { metric in
stateBox.renderPasses.append(metric)
}
)
self.coordinator = parent.makeCoordinator()
self.coordinator.currentLineIndex = lineIndex
self.scrollView = ComfortableEditorScrollView()
self.scrollView.hasVerticalScroller = true
self.scrollView.hasHorizontalScroller = false
self.scrollView.autohidesScrollers = true
self.scrollView.borderType = .noBorder
self.scrollView.drawsBackground = true
self.scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
self.textView = EditorTextView(
frame: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
textContainer: textContainer
)
self.textView.autoresizingMask = [.width]
self.textView.minSize = NSSize(width: 0, height: 480)
self.textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
self.textView.isVerticallyResizable = true
self.textView.isHorizontallyResizable = false
self.textView.onFocusStateChange = { [weak coordinator] textView in
coordinator?.applyHybridAttributes(to: textView, cause: .focusChange)
}
self.textView.onUserEditingInteraction = { [weak coordinator] textView, interactionKind in
coordinator?.activateEditingPresentation(in: textView, interactionKind: interactionKind)
}
self.textView.string = source
self.textView.delegate = coordinator
self.textView.setSelectedRange(selectedRange)
self.textView.isRichText = false
self.textView.isEditable = true
self.textView.isSelectable = true
self.textView.isAutomaticQuoteSubstitutionEnabled = false
self.textView.isAutomaticDashSubstitutionEnabled = false
self.textView.isAutomaticTextReplacementEnabled = false
self.textView.allowsUndo = true
self.textView.usesFindPanel = true
self.textView.isContinuousSpellCheckingEnabled = true
self.textView.backgroundColor = .textBackgroundColor
self.textView.insertionPointColor = .controlAccentColor
self.textView.font = .systemFont(ofSize: 16, weight: .regular)
self.textView.textContainer?.widthTracksTextView = true
self.textView.textContainer?.containerSize = NSSize(width: initialWidth, height: CGFloat.greatestFiniteMagnitude)
self.scrollView.frame = NSRect(x: 0, y: 0, width: initialWidth, height: 480)
self.scrollView.documentView = textView
self.scrollView.editorTextView = textView
self.scrollView.onEditorLayoutInvalidated = { [weak coordinator] textView in
coordinator?.syncChecklistControlFrames(in: textView)
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
}
self.scrollView.updateEditorInsets()
self.window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
self.window.contentView = scrollView
self.coordinator.applyHybridAttributes(to: textView)
syncState()
}
public func simulateLaunchFirstResponder() {
window.makeFirstResponder(textView)
syncState()
}
public func simulateFocusAway() {
window.makeFirstResponder(nil)
syncState()
}
public func setSelection(_ range: NSRange) {
setSelection(range, interactionKind: .programmatic)
}
public func setSelectionByKeyboard(_ range: NSRange) {
setSelection(range, interactionKind: .keyboard)
}
public func setSelectionByMouse(_ range: NSRange) {
setSelection(range, interactionKind: .mouse)
}
private func setSelection(_ range: NSRange, interactionKind: EditorInteractionKind) {
coordinator.activateEditingPresentation(in: textView, interactionKind: interactionKind)
coordinator.setSelection(range, in: textView, interactionKind: interactionKind)
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
syncState()
}
public func clickRenderedCheckbox(lineIndex: Int) {
guard let button = checklistButton(lineIndex: lineIndex) else { return }
button.performClick(nil)
syncState()
}
public func simulateLayout(width: CGFloat) {
window.setContentSize(NSSize(width: width, height: 480))
scrollView.frame = NSRect(x: 0, y: 0, width: width, height: 480)
textView.frame = NSRect(x: 0, y: 0, width: width, height: textView.frame.height)
textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
scrollView.layoutSubtreeIfNeeded()
scrollView.layout()
syncState()
}
public func scrollViewport(toY y: CGFloat) {
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
let target = NSPoint(x: 0, y: max(0, min(y, maxY)))
scrollView.contentView.scroll(to: target)
scrollView.reflectScrolledClipView(scrollView.contentView)
syncState()
}
public func viewportOrigin() -> CGPoint {
scrollView.contentView.bounds.origin
}
public func headingMarkerIsHidden() -> Bool {
isHidden(at: 0)
}
public func characterIsHidden(at location: Int) -> Bool {
isHidden(at: location)
}
public func point(for text: String) -> CGPoint? {
let textRange = (textView.string as NSString).range(of: text)
guard textRange.location != NSNotFound,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer
else { return nil }
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 fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
let origin = textView.textContainerOrigin
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
}
public func viewportPoint(for text: String) -> CGPoint? {
guard let point = point(for: text) else { return nil }
let origin = viewportOrigin()
return CGPoint(x: point.x - origin.x, y: point.y - origin.y)
}
public func viewportStabilityEventDescriptions() -> [String] {
coordinator.viewportStabilityEvents.map {
[
"cause=\($0.cause)",
"reason=\($0.renderReason)",
"decision=\($0.decision)",
"captured=\($0.capturedAnchor)",
"restored=\($0.restored)"
].joined(separator: "|")
}
}
public func presentationSignature() -> String {
guard let storage = textView.textStorage else { return "" }
return MarkdownPresentationSnapshot.make(
from: storage,
lineIndex: coordinator.currentLineIndex,
containerWidth: textView.textContainer?.containerSize.width ?? 640
).signature
}
public func checklistButtonFrame(lineIndex: Int) -> CGRect? {
checklistButton(lineIndex: lineIndex)?.frame
}
public func checklistAlignmentDelta(lineIndex: Int) -> CGFloat? {
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
else { return nil }
return abs(buttonFrame.midY - labelFrame.midY)
}
public func checklistLabelGap(lineIndex: Int) -> CGFloat? {
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
else { return nil }
return labelFrame.minX - buttonFrame.maxX
}
public func codeBlockContainerCount() -> Int {
textView.codeBlockContainers.count
}
public func codeBlockContainerLabel(containing lineIndex: Int) -> String? {
textView.codeBlockContainers.first { $0.codeBlock.lineIndexes.contains(lineIndex) }?.languageLabel
}
public func codeBlockContainerFrame(containing lineIndex: Int) -> CGRect? {
textView.codeBlockContainerFrame(containing: lineIndex)
}
public func selectedRange() -> NSRange {
textView.selectedRange()
}
public func source() -> String {
textView.string
}
public func effectiveActiveLineIndex() -> Int {
coordinator.currentLineIndex.lineIndex(containing: textView.selectedRange().location)
}
private func checklistButton(lineIndex: Int) -> ChecklistOverlayButton? {
textView.subviews.compactMap { $0 as? ChecklistOverlayButton }.first { $0.task?.lineIndex == lineIndex }
}
private func checklistLabelFrame(lineIndex: Int) -> CGRect? {
guard let task = checklistButton(lineIndex: lineIndex)?.task,
task.contentRange.location < textView.string.utf16.count,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer
else { return nil }
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: NSRange(location: task.contentRange.location, length: 1),
actualCharacterRange: nil
)
guard glyphRange.length > 0 else { return nil }
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let origin = textView.textContainerOrigin
return CGRect(
x: origin.x + fragment.minX,
y: origin.y + fragment.minY,
width: fragment.width,
height: fragment.height
)
}
private func isHidden(at location: Int) -> Bool {
guard let textStorage = textView.textStorage else { return false }
let color = textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
let font = textStorage.attribute(.font, at: location, effectiveRange: nil) as? NSFont
return color?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
}
private func syncState() {
text = box.text
selection = box.selection
renderPasses = box.renderPasses
}
private final class StateBox {
var text: String
var selection: EditorSelection
var lineIndex: DocumentLineIndex
var renderPasses: [EditorRenderPassMetric] = []
init(text: String, selection: EditorSelection) {
self.text = text
self.selection = selection
self.lineIndex = DocumentLineIndex(source: text)
}
}
}
#endif
#elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.text = text
textView.delegate = context.coordinator
textView.allowsEditingTextAttributes = false
textView.autocorrectionType = .yes
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.font = .systemFont(ofSize: 17, weight: .regular)
textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
textView.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
if textView.text != text {
context.coordinator.performProgrammaticUpdate {
textView.text = text
}
context.coordinator.invalidateStylingCache()
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var lastStyledEditableRegion: EditableRegion?
private var pendingEdit: DocumentLineIndexEdit?
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
pendingEdit = DocumentLineIndexEdit(range: range, replacement: text)
return true
}
func textViewDidChange(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
let selection = EditorSelection(range: textView.selectedRange)
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit, updatedSource: textView.text)
} else {
currentLineIndex = DocumentLineIndex(source: textView.text)
}
parent.onTextEdit(textView.text, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView)
pendingEdit = nil
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
let newSelection = EditorSelection(range: textView.selectedRange)
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView)
parent.selection = newSelection
}
func applyHybridAttributes(to textView: UITextView) {
let editableRegion = textView.isFirstResponder
? EditableRegion.selection(textView.selectedRange, in: currentLineIndex)
: .none()
let activeLineIndex = editableRegion.primaryLineIndex
let invalidationPlan = invalidationPlan(for: textView.text, editableRegion: editableRegion)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange
let contentOffset = textView.contentOffset
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreContentOffset = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textView.textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
textColor: .label,
secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue,
editableRegion: editableRegion
)
if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange
}
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
didRestoreContentOffset = true
}
lastStyledText = textView.text
lastStyledActiveLineIndex = activeLineIndex
lastStyledEditableRegion = editableRegion
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.text.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreContentOffset
))
}
func textViewDidBeginEditing(_ textView: UITextView) {
applyHybridAttributes(to: textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
applyHybridAttributes(to: textView)
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
lastStyledEditableRegion = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
let previousEditableRegion = lastStyledEditableRegion
let plan = EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
currentActiveLineIndex: editableRegion.primaryLineIndex
)
return plan.includingEditableRegionTransition(
from: previousEditableRegion,
to: editableRegion,
lineCount: currentLineIndex.lineCount
)
}
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
let maxX = max(0, textView.contentSize.width - textView.bounds.width)
let maxY = max(0, textView.contentSize.height - textView.bounds.height)
return CGPoint(x: max(0, min(offset.x, maxX)), y: max(0, min(offset.y, maxY)))
}
}
}
#endif
struct MarkdownTextStylingResult {
var totalLineCount: Int
var styledLineCount: Int
var styledLineIndexes: [Int]
var renderedTasks: [RenderedTaskElement]
var renderedCodeBlocks: [RenderedCodeBlockElement]
var editableRegion: EditableRegion
static let empty = MarkdownTextStylingResult(
totalLineCount: 0,
styledLineCount: 0,
styledLineIndexes: [],
renderedTasks: [],
renderedCodeBlocks: [],
editableRegion: .none()
)
}
enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
#elseif os(iOS)
typealias PlatformColor = UIColor
#endif
@discardableResult
static func apply(
to textStorage: NSTextStorage,
lineIndex: DocumentLineIndex,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
usesRenderedControls: Bool = false,
editableRegion: EditableRegion? = nil
) -> MarkdownTextStylingResult {
let resolvedEditableRegion = editableRegion
?? (activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none())
let fullRange = NSRange(location: 0, length: textStorage.length)
guard fullRange.length > 0 else {
return MarkdownTextStylingResult(
totalLineCount: lineIndex.lineCount,
styledLineCount: lineIndex.lineCount,
styledLineIndexes: Array(0..<lineIndex.lineCount),
renderedTasks: [],
renderedCodeBlocks: [],
editableRegion: resolvedEditableRegion
)
}
textStorage.beginEditing()
if invalidationPlan.isFullRender {
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
}
let presentationState = DocumentPresentationState(
lineIndex: lineIndex,
editableRegion: resolvedEditableRegion,
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
)
var styledLineCount = 0
var styledLineIndexes: [Int] = []
for presentationLine in presentationState.lines {
let line = presentationLine.line
let paragraphRange = presentationRange(for: line, in: lineIndex, textLength: textStorage.length)
resetAttributes(in: textStorage, range: paragraphRange, textColor: textColor)
styledLineCount += 1
styledLineIndexes.append(line.index)
switch presentationLine.state {
case .source:
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 15, weight: .regular)
], 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,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
usesRenderedControls: usesRenderedControls
)
}
}
textStorage.endEditing()
return MarkdownTextStylingResult(
totalLineCount: presentationState.lineCount,
styledLineCount: styledLineCount,
styledLineIndexes: styledLineIndexes,
renderedTasks: presentationState.renderedTasks,
renderedCodeBlocks: presentationState.renderedCodeBlocks,
editableRegion: presentationState.editableRegion
)
}
private static func resetAttributes(
in textStorage: NSTextStorage,
range: NSRange,
textColor: PlatformColor
) {
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,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
usesRenderedControls: Bool
) {
guard line.range.length > 0 else { return }
switch renderPlan.kind {
case .heading(let level, let markerRange, let textRange):
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
)
textStorage.addAttributes([
.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([
.foregroundColor: accentColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.foregroundColor: textColor,
.backgroundColor: accentColor.withAlphaComponent(0.08),
.paragraphStyle: blockquoteParagraphStyle()
], range: paragraphRange)
textStorage.addAttributes([
.font: italicSystemFont(size: 16)
], range: contentRange)
case .horizontalRule(let range):
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.strikethroughStyle: NSUnderlineStyle.thick.rawValue,
.paragraphStyle: horizontalRuleParagraphStyle()
], range: range)
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .orderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
styleTaskListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
checkboxRange: checkboxRange,
contentRange: contentRange,
checked: checked,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
backgroundColor: backgroundColor,
usesRenderedControls: usesRenderedControls
)
if checked {
textStorage.addAttributes([
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: secondaryTextColor
], range: contentRange)
}
case .fencedCodeFence(_, _, let role):
textStorage.addAttributes(codeBlockFenceAttributes(role: role), range: paragraphRange)
hideSyntax(in: textStorage, range: line.range)
case .codeBlockContent(let language):
textStorage.addAttributes(codeBlockContentAttributes(), range: paragraphRange)
styleCodeSyntax(
in: textStorage,
line: line,
language: language,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
case .tableRow(_, let separatorRanges, let isDivider):
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.06),
.paragraphStyle: tableParagraphStyle()
], range: paragraphRange)
for separatorRange in separatorRanges {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
}
if isDivider {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: paragraphRange)
}
case .paragraph:
break
}
styleInlineSpans(
in: textStorage,
renderPlan: renderPlan,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
private static func styleInlineSpans(
in textStorage: NSTextStorage,
renderPlan: HybridMarkdownLineRenderPlan,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
for span in renderPlan.spans {
switch span.kind {
case .bold:
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: span.range)
case .italic:
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: span.range)
case .inlineCode:
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.12)
], range: span.range)
case .link, .automaticLink:
textStorage.addAttributes([
.foregroundColor: accentColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
], range: span.range)
case .markdownDelimiter:
hideSyntax(in: textStorage, range: span.range)
}
}
}
private static func styleCodeSyntax(
in textStorage: NSTextStorage,
line: EditorLine,
language: String?,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
let normalizedLanguage = normalizedCodeLanguage(language)
guard normalizedLanguage != "text" else { return }
switch normalizedLanguage {
case "swift":
applyCodeRegex("\\b(import|struct|class|enum|protocol|extension|func|let|var|if|else|guard|return|for|while|switch|case|default|in|try|throw|throws|async|await|public|private|internal|static|final|init|self|nil|true|false)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("//.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
case "json":
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"\\s*:", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex(":\\s*(\"(?:\\\\.|[^\"\\\\])*\")", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range(at: 1))
}
applyCodeRegex("\\b(true|false|null)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeKeywordColor(accentColor: accentColor)], range: match.range)
}
applyCodeRegex("-?\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
case "yaml":
applyCodeRegex("^\\s*[A-Za-z0-9_-]+\\s*:", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("#.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
applyCodeRegex("\\b(true|false|null|yes|no)\\b", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
}
case "markdown":
applyCodeRegex("^\\s{0,3}#{1,6}\\s+.*$", in: textStorage, line: line) { match in
textStorage.addAttributes([
.foregroundColor: codeKeywordColor(accentColor: accentColor),
.font: monospacedFont(size: 15, weight: .semibold)
], range: match.range)
}
applyCodeRegex("(\\*\\*[^*]+\\*\\*|`[^`]+`|\\[[^\\]]+\\]\\([^)]+\\))", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: accentColor], range: match.range)
}
default:
textStorage.addAttributes([.foregroundColor: textColor], range: line.range)
}
}
private static func normalizedCodeLanguage(_ language: String?) -> String {
let normalized = language?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "swift", "json", "yaml", "yml":
return normalized == "yml" ? "yaml" : normalized ?? "text"
case "md", "markdown":
return "markdown"
default:
return "text"
}
}
private static func styleSourceLineMarkers(
in textStorage: NSTextStorage,
line: EditorLine,
secondaryTextColor: PlatformColor
) {
applyRegex("(#{1,6}|\\*\\*|__|\\*|_|`|\\[[ xX]\\]|\\[|\\]|\\(|\\)|\\||>|-{3,}|[-*]|\\d+[.)])", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
}
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 5
return [
.font: systemFont(size: 16, weight: .regular),
.foregroundColor: textColor,
.paragraphStyle: paragraph
]
}
private static func applyRegex(
_ pattern: String,
in textStorage: NSTextStorage,
line: EditorLine,
handler: (NSTextCheckingResult) -> Void
) {
guard line.range.length > 0,
let regex = try? NSRegularExpression(pattern: pattern)
else { return }
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
}
private static func applyCodeRegex(
_ pattern: String,
in textStorage: NSTextStorage,
line: EditorLine,
handler: (NSTextCheckingResult) -> Void
) {
guard line.range.length > 0,
let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines])
else { return }
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
}
private static func headingFontSize(level: Int) -> CGFloat {
switch level {
case 1: 28
case 2: 23
case 3: 20
default: 17
}
}
private static func headingParagraphStyle(level: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacingBefore = level <= 2 ? 10 : 6
paragraph.paragraphSpacing = level <= 2 ? 9 : 6
return paragraph
}
private static func blockquoteParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 6
paragraph.headIndent = 18
paragraph.firstLineHeadIndent = 0
return paragraph
}
private static func horizontalRuleParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.paragraphSpacingBefore = 10
paragraph.paragraphSpacing = 10
return paragraph
}
private static func listParagraphStyle(nestingLevel: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
let indent = CGFloat(20 + nestingLevel * 18)
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 4
paragraph.firstLineHeadIndent = 0
paragraph.headIndent = indent
return paragraph
}
private static func tableParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 2
return paragraph
}
private static func codeBlockContentParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 0
paragraph.paragraphSpacingBefore = 0
paragraph.firstLineHeadIndent = 18
paragraph.headIndent = 18
return paragraph
}
private static func codeBlockFenceParagraphStyle(role: FencedCodeFenceRole) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 0
paragraph.paragraphSpacing = role == .closing ? 10 : 0
paragraph.paragraphSpacingBefore = role == .opening ? 8 : 0
paragraph.minimumLineHeight = role == .opening ? 30 : 12
paragraph.maximumLineHeight = role == .opening ? 30 : 12
paragraph.firstLineHeadIndent = 18
paragraph.headIndent = 18
return paragraph
}
private static func codeBlockContentAttributes() -> [NSAttributedString.Key: Any] {
[
.font: monospacedFont(size: 15, weight: .regular),
.paragraphStyle: codeBlockContentParagraphStyle()
]
}
private static func codeBlockFenceAttributes(role: FencedCodeFenceRole) -> [NSAttributedString.Key: Any] {
[
.font: monospacedFont(size: 15, weight: .regular),
.paragraphStyle: codeBlockFenceParagraphStyle(role: role)
]
}
private static func styleListLine(
in textStorage: NSTextStorage,
paragraphRange: NSRange,
markerRange: NSRange,
contentRange: NSRange,
nestingLevel: Int,
secondaryTextColor: PlatformColor
) {
textStorage.addAttributes([
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
], range: paragraphRange)
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.font: systemFont(size: 16, weight: .regular)
], range: contentRange)
}
private static func styleTaskListLine(
in textStorage: NSTextStorage,
paragraphRange: NSRange,
markerRange: NSRange,
checkboxRange: NSRange,
contentRange: NSRange,
checked: Bool,
nestingLevel: Int,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
backgroundColor: PlatformColor,
usesRenderedControls: Bool
) {
textStorage.addAttributes([
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
], range: paragraphRange)
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location)
)
textStorage.addAttributes([
.foregroundColor: checked ? accentColor : secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold),
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
], range: checkboxRange)
if usesRenderedControls {
hideSyntaxPreservingLayout(in: textStorage, range: checkboxRange, backgroundColor: backgroundColor)
}
textStorage.addAttributes([
.font: systemFont(size: 16, weight: .regular)
], range: contentRange)
}
private static func hideSyntax(in textStorage: NSTextStorage, range: NSRange) {
guard range.length > 0 else { return }
textStorage.addAttributes([
.foregroundColor: clearColor(),
.font: monospacedFont(size: 0.1, weight: .regular)
], 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)
}
private static func italicSystemFont(size: CGFloat) -> NSFont {
NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask)
}
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
}
private static func clearColor() -> NSColor {
.clear
}
private static func codeKeywordColor(accentColor: NSColor) -> NSColor {
.systemPurple
}
private static func codeStringColor(accentColor: NSColor) -> NSColor {
.systemRed
}
private static func codeNumberColor(accentColor: NSColor) -> NSColor {
.systemOrange
}
#elseif os(iOS)
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> UIFont {
UIFont.italicSystemFont(ofSize: size)
}
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
}
private static func clearColor() -> UIColor {
.clear
}
private static func codeKeywordColor(accentColor: UIColor) -> UIColor {
.systemPurple
}
private static func codeStringColor(accentColor: UIColor) -> UIColor {
.systemRed
}
private static func codeNumberColor(accentColor: UIColor) -> UIColor {
.systemOrange
}
#endif
}
private extension NSRange {
var upperBound: Int {
location + length
}
}
#if os(macOS)
private extension NSTextView {
func presentationAnchorPoint(at location: Int) -> NSPoint? {
guard let layoutManager,
string.utf16.count > 0
else { return nil }
let clampedLocation = min(max(0, location), string.utf16.count - 1)
let characterRange = NSRange(location: clampedLocation, length: 1)
layoutManager.ensureLayout(forCharacterRange: characterRange)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: characterRange,
actualCharacterRange: nil
)
guard glyphRange.length > 0 else { return nil }
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
let origin = textContainerOrigin
return NSPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
}
}
#endif
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}