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 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 } 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 = currentLineIndex.lineIndex(containing: textView.selectedRange().location) 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 )) } 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 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 } 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 replacement = task.checked ? "[ ]" : "[x]" pendingEdit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement) guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else { pendingEdit = nil return } textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement) textView.setSelectedRange(NSRange(location: task.checkboxRange.upperBound, length: 0)) textView.didChangeText() 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) 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 { override var acceptsFirstResponder: Bool { true } } private final class ChecklistOverlayButton: NSButton { var task: RenderedTaskElement? var onToggle: (() -> Void)? init() { super.init(frame: .zero) setButtonType(.switch) title = "" isBordered = false imagePosition = .imageOnly target = self action = #selector(toggleCheckbox) } @available(*, unavailable) required init?(coder: NSCoder) { nil } @objc private func toggleCheckbox() { onToggle?() } } 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 activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange.location) 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 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, 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: line.range) } private static func styleRenderedLine( in textStorage: NSTextStorage, line: EditorLine, 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([ .font: systemFont(size: headingFontSize(level: level), weight: .semibold), .paragraphStyle: headingParagraphStyle(level: level) ], 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: line.range) 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, lineRange: line.range, markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel, secondaryTextColor: secondaryTextColor ) case .orderedList(let markerRange, let contentRange, let nestingLevel): styleListLine( in: textStorage, lineRange: line.range, markerRange: markerRange, contentRange: contentRange, nestingLevel: nestingLevel, secondaryTextColor: secondaryTextColor ) case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel): styleTaskListLine( in: textStorage, lineRange: line.range, 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: line.range) 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: line.range) case .tableRow(_, let separatorRanges, let isDivider): textStorage.addAttributes([ .font: monospacedFont(size: 15, weight: .regular), .backgroundColor: accentColor.withAlphaComponent(0.06), .paragraphStyle: tableParagraphStyle() ], range: line.range) for separatorRange in separatorRanges { textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange) } if isDivider { textStorage.addAttributes([ .foregroundColor: secondaryTextColor, .font: monospacedFont(size: 15, weight: .semibold) ], range: line.range) } 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, lineRange: NSRange, markerRange: NSRange, contentRange: NSRange, nestingLevel: Int, secondaryTextColor: PlatformColor ) { textStorage.addAttributes([ .paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel) ], range: lineRange) 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, lineRange: 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: lineRange) 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 { hideSyntax(in: textStorage, range: checkboxRange) } 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) } #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 }