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) } textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in coordinator?.activateEditingPresentation(in: textView) } 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) } 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) } 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 pendingEdit: DocumentLineIndexEdit? private var hasUserActivatedEditing = false private var checklistButtons: [Int: ChecklistOverlayButton] = [:] 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) 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 } applyHybridAttributes(to: textView) parent.selection = newSelection } func applyHybridAttributes(to textView: NSTextView) { guard let textStorage = textView.textStorage else { return } let activeLineIndex = presentationActiveLineIndex(in: textView) let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex) guard invalidationPlan.requiresStyling else { return } let selectedRange = textView.selectedRange() let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin 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 ) if textView.selectedRange() != selectedRange, selectedRange.location <= textView.string.utf16.count { textView.setSelectedRange(selectedRange) } didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView) } lastStyledText = textView.string lastStyledActiveLineIndex = activeLineIndex syncChecklistControls( in: textView, stylingResult: stylingResult, invalidationPlan: invalidationPlan, activeLineIndex: activeLineIndex ) 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 presentationActiveLineIndex(in textView: NSTextView) -> Int { guard hasUserActivatedEditing, textView.window?.firstResponder === textView else { return -1 } return currentLineIndex.lineIndex(containing: textView.selectedRange().location) } func activateEditingPresentation(in textView: NSTextView) { guard !hasUserActivatedEditing else { return } hasUserActivatedEditing = true applyHybridAttributes(to: textView) } func setSelection(_ range: NSRange, in textView: NSTextView) { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { textView.setSelectedRange(range) textView.scrollRangeToVisible(range) } } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil hasUserActivatedEditing = false removeChecklistControls() } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan { EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, previousActiveLineIndex: lastStyledActiveLineIndex, currentActiveLineIndex: activeLineIndex ) } 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 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 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 restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool { guard let origin, 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 final class EditorTextView: NSTextView { var onFocusStateChange: ((NSTextView) -> Void)? var onUserEditingInteraction: ((NSTextView) -> Void)? override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { let becameFirstResponder = super.becomeFirstResponder() if becameFirstResponder { onFocusStateChange?(self) } return becameFirstResponder } override func resignFirstResponder() -> Bool { let resignedFirstResponder = super.resignFirstResponder() if resignedFirstResponder { onFocusStateChange?(self) } return resignedFirstResponder } override func mouseDown(with event: NSEvent) { onUserEditingInteraction?(self) super.mouseDown(with: event) } override func keyDown(with event: NSEvent) { onUserEditingInteraction?(self) super.keyDown(with: event) } override func paste(_ sender: Any?) { onUserEditingInteraction?(self) super.paste(sender) } } 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) } self.textView.onUserEditingInteraction = { [weak coordinator] textView in coordinator?.activateEditingPresentation(in: textView) } 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) } 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) { coordinator.activateEditingPresentation(in: textView) coordinator.setSelection(range, in: textView) 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 headingMarkerIsHidden() -> Bool { isHidden(at: 0) } 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 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 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 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 activeLineIndex = textView.isFirstResponder ? currentLineIndex.lineIndex(containing: textView.selectedRange.location) : -1 let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex) 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 ) 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 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 } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan { EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, previousActiveLineIndex: lastStyledActiveLineIndex, currentActiveLineIndex: activeLineIndex ) } 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] static let empty = MarkdownTextStylingResult( totalLineCount: 0, styledLineCount: 0, styledLineIndexes: [], renderedTasks: [] ) } 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 ) -> MarkdownTextStylingResult { let fullRange = NSRange(location: 0, length: textStorage.length) guard fullRange.length > 0 else { return MarkdownTextStylingResult( totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount, styledLineIndexes: Array(0.. 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 markerRange, let languageRange): textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange) if let languageRange { hideSyntax( in: textStorage, range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location) ) textStorage.addAttributes([ .foregroundColor: accentColor, .font: monospacedFont(size: 13, weight: .semibold) ], range: languageRange) } else { hideSyntax(in: textStorage, range: line.range) } case .codeBlockContent: textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange) case .tableRow(_, let separatorRanges, let isDivider): textStorage.addAttributes([ .font: monospacedFont(size: 15, weight: .regular), .backgroundColor: accentColor.withAlphaComponent(0.06), .paragraphStyle: tableParagraphStyle() ], range: 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 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 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 codeBlockParagraphStyle() -> NSMutableParagraphStyle { let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 3 paragraph.paragraphSpacing = 2 return paragraph } private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] { [ .font: monospacedFont(size: 15, weight: .regular), .backgroundColor: accentColor.withAlphaComponent(0.08), .paragraphStyle: codeBlockParagraphStyle() ] } 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 } #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 } #endif } private extension NSRange { var upperBound: Int { location + length } } private var platformTextBackground: Color { #if os(macOS) Color(nsColor: .textBackgroundColor) #else Color(uiColor: .systemBackground) #endif }