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.delegate = context.coordinator textView.string = text 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.updateEditorInsets() context.coordinator.applyHybridAttributes(to: textView) context.coordinator.requestInitialFocus(for: 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) context.coordinator.requestInitialFocus(for: 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 didFocusTextView = false 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 } 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 invalidationPlan = invalidationPlan(for: textView.string) 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: parent.activeLineIndex, backgroundColor: .textBackgroundColor, activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10), textColor: .labelColor, secondaryTextColor: .secondaryLabelColor, accentColor: .controlAccentColor ) if textView.selectedRange() != selectedRange, selectedRange.location <= textView.string.utf16.count { textView.setSelectedRange(selectedRange) } didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView) } lastStyledText = textView.string lastStyledActiveLineIndex = parent.activeLineIndex parent.onRenderPass(EditorRenderPassMetric( reason: invalidationPlan.reason, durationMilliseconds: Date().timeIntervalSince(start) * 1000, characterCount: textView.string.utf16.count, lineCount: stylingResult.totalLineCount, dirtyLineCount: stylingResult.styledLineCount, activeLineIndex: parent.activeLineIndex, isFullRender: invalidationPlan.isFullRender, restoredScrollPosition: didRestoreVisibleOrigin )) } func setSelection(_ range: NSRange, in textView: NSTextView) { guard textView.selectedRange() != range else { return } performProgrammaticUpdate { textView.setSelectedRange(range) textView.scrollRangeToVisible(range) } } func requestInitialFocus(for textView: NSTextView) { guard !didFocusTextView else { return } DispatchQueue.main.async { [weak self, weak textView] in guard let self, let textView, let window = textView.window, !self.didFocusTextView else { return } if window.firstResponder !== textView { window.makeFirstResponder(textView) } self.didFocusTextView = window.firstResponder === textView } } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan { EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, previousActiveLineIndex: lastStyledActiveLineIndex, currentActiveLineIndex: parent.activeLineIndex ) } 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 { override var acceptsFirstResponder: Bool { true } } private final class ComfortableEditorScrollView: NSScrollView { weak var editorTextView: NSTextView? override func layout() { super.layout() updateEditorInsets() } func updateEditorInsets() { guard let editorTextView else { return } 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 } } } #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.delegate = context.coordinator textView.text = text 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) context.coordinator.requestInitialFocus(for: 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) context.coordinator.requestInitialFocus(for: 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? private var didFocusTextView = false 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 invalidationPlan = invalidationPlan(for: textView.text) 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: parent.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 = parent.activeLineIndex parent.onRenderPass(EditorRenderPassMetric( reason: invalidationPlan.reason, durationMilliseconds: Date().timeIntervalSince(start) * 1000, characterCount: textView.text.utf16.count, lineCount: stylingResult.totalLineCount, dirtyLineCount: stylingResult.styledLineCount, activeLineIndex: parent.activeLineIndex, isFullRender: invalidationPlan.isFullRender, restoredScrollPosition: didRestoreContentOffset )) } func performProgrammaticUpdate(_ updates: () -> Void) { programmaticUpdateDepth += 1 defer { programmaticUpdateDepth -= 1 } updates() } func requestInitialFocus(for textView: UITextView) { guard !didFocusTextView else { return } DispatchQueue.main.async { [weak self, weak textView] in guard let self, let textView, textView.window != nil, !self.didFocusTextView else { return } self.didFocusTextView = textView.becomeFirstResponder() } } func invalidateStylingCache() { lastStyledText = nil lastStyledActiveLineIndex = nil } private var isPerformingProgrammaticUpdate: Bool { programmaticUpdateDepth > 0 } private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan { EditorDirtyLineInvalidator.plan( previousText: lastStyledText, currentLineIndex: currentLineIndex, edit: pendingEdit, previousActiveLineIndex: lastStyledActiveLineIndex, currentActiveLineIndex: parent.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 static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0) } 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 ) -> MarkdownTextStylingResult { let fullRange = NSRange(location: 0, length: textStorage.length) guard fullRange.length > 0 else { return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: lineIndex.lineCount) } textStorage.beginEditing() if invalidationPlan.isFullRender { textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange) } let renderer = HybridMarkdownLineRenderer() let lines = invalidationPlan.isFullRender ? lineIndex.editorLines(activeLineIndex: activeLineIndex) : lineIndex.editorLines(for: invalidationPlan.dirtyLineIndexes, activeLineIndex: activeLineIndex) var styledLineCount = 0 for line in lines { resetAttributes(in: textStorage, line: line, textColor: textColor) styledLineCount += 1 if line.index == activeLineIndex { textStorage.addAttributes([ .backgroundColor: activeLineBackgroundColor, .font: monospacedFont(size: 15, weight: .regular) ], range: line.range) styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor) } else { styleRenderedLine( in: textStorage, line: line, renderPlan: renderer.renderPlan(for: line), secondaryTextColor: secondaryTextColor, accentColor: accentColor ) } } textStorage.endEditing() return MarkdownTextStylingResult(totalLineCount: lineIndex.lineCount, styledLineCount: styledLineCount) } private static func resetAttributes( in textStorage: NSTextStorage, line: EditorLine, textColor: PlatformColor ) { guard line.range.length > 0 else { return } textStorage.setAttributes(baseAttributes(textColor: textColor), range: line.range) } private static func styleRenderedLine( in textStorage: NSTextStorage, line: EditorLine, renderPlan: HybridMarkdownLineRenderPlan, secondaryTextColor: PlatformColor, accentColor: PlatformColor ) { guard line.range.length > 0 else { return } if case .heading(let level, let markerRange, let textRange) = renderPlan.kind { textStorage.addAttributes([ .foregroundColor: secondaryTextColor, .font: monospacedFont(size: 13, weight: .regular) ], range: markerRange) textStorage.addAttributes([ .font: systemFont(size: headingFontSize(level: level), weight: .semibold) ], range: textRange) } 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 .markdownDelimiter: textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range) } } } private static func styleSourceLineMarkers( in textStorage: NSTextStorage, line: EditorLine, secondaryTextColor: PlatformColor ) { applyRegex("(#{1,6}|\\*\\*|\\*|`)", 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 } } #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) } #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) } #endif } private extension NSRange { var upperBound: Int { location + length } } private var platformTextBackground: Color { #if os(macOS) Color(nsColor: .textBackgroundColor) #else Color(uiColor: .systemBackground) #endif }