2026-05-29 17:55:37 +02:00
|
|
|
import Foundation
|
2026-05-29 15:19:33 +02:00
|
|
|
import SwiftUI
|
|
|
|
|
import SaplingCore
|
|
|
|
|
import SaplingRenderer
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
#elseif os(iOS)
|
|
|
|
|
import UIKit
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-05-29 15:19:33 +02:00
|
|
|
@MainActor
|
2026-05-29 17:55:37 +02:00
|
|
|
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
|
|
|
|
|
@Published public private(set) var state: EditorState
|
2026-05-29 20:08:46 +02:00
|
|
|
public private(set) var instrumentation = EditorInstrumentationSnapshot()
|
2026-05-29 15:19:33 +02:00
|
|
|
|
|
|
|
|
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
|
2026-05-29 17:55:37 +02:00
|
|
|
self.state = EditorState(
|
|
|
|
|
document: EditorDocument(markdownDocument: document),
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public var document: MarkdownDocument {
|
|
|
|
|
state.document.markdownDocument
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public var hasUnsavedChanges: Bool {
|
|
|
|
|
state.hasUnsavedChanges
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public var activeLineIndex: Int {
|
|
|
|
|
state.activeLineIndex
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:24:48 +02:00
|
|
|
public var lineIndex: DocumentLineIndex {
|
|
|
|
|
state.lineIndex
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public func replaceDocument(_ document: EditorDocument) {
|
|
|
|
|
state = EditorState(document: document)
|
2026-05-29 20:08:46 +02:00
|
|
|
instrumentation = EditorInstrumentationSnapshot()
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func updateSource(_ source: String) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard state.document.source != source else { return }
|
2026-05-29 20:08:46 +02:00
|
|
|
let previousActiveLineIndex = state.activeLineIndex
|
2026-05-29 17:55:37 +02:00
|
|
|
state.updateSource(source)
|
2026-05-29 20:08:46 +02:00
|
|
|
instrumentation.recordSourceChange()
|
|
|
|
|
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:24:48 +02:00
|
|
|
public func updateSource(_ source: String, edit: DocumentLineIndexEdit?, selection: EditorSelection? = nil) {
|
|
|
|
|
guard state.document.source != source else { return }
|
|
|
|
|
let previousActiveLineIndex = state.activeLineIndex
|
|
|
|
|
if let edit {
|
2026-05-30 19:28:34 +02:00
|
|
|
state.updateSource(source, edit: edit, selection: selection)
|
2026-05-30 19:24:48 +02:00
|
|
|
} else {
|
|
|
|
|
state.updateSource(source)
|
|
|
|
|
if let selection {
|
|
|
|
|
state.updateSelection(selection)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
instrumentation.recordSourceChange()
|
|
|
|
|
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public func updateSelection(_ selection: EditorSelection) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard state.selection != selection else { return }
|
2026-05-29 20:08:46 +02:00
|
|
|
let previousActiveLineIndex = state.activeLineIndex
|
2026-05-29 17:55:37 +02:00
|
|
|
state.updateSelection(selection)
|
2026-05-29 20:08:46 +02:00
|
|
|
instrumentation.recordSelectionChange()
|
|
|
|
|
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
|
|
|
|
|
instrumentation.recordRenderPass(metric)
|
2026-05-29 20:57:03 +02:00
|
|
|
#if DEBUG
|
|
|
|
|
EditorDiagnostics.logRenderPass(metric)
|
|
|
|
|
#endif
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
|
|
|
|
|
private func recordActiveLineChangeIfNeeded(_ previousActiveLineIndex: Int) {
|
|
|
|
|
if previousActiveLineIndex != state.activeLineIndex {
|
|
|
|
|
instrumentation.recordActiveLineChange()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public struct HybridMarkdownEditor: View, EditorView {
|
2026-05-29 15:19:33 +02:00
|
|
|
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
|
|
|
|
|
private let renderer: any MarkdownRendering
|
|
|
|
|
|
|
|
|
|
public init(
|
|
|
|
|
viewModel: HybridMarkdownEditorViewModel,
|
|
|
|
|
renderer: any MarkdownRendering = MarkdownRenderer()
|
|
|
|
|
) {
|
|
|
|
|
self.viewModel = viewModel
|
|
|
|
|
self.renderer = renderer
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
public var state: EditorState {
|
|
|
|
|
viewModel.state
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 15:19:33 +02:00
|
|
|
public var body: some View {
|
2026-05-29 17:55:37 +02:00
|
|
|
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) }
|
|
|
|
|
),
|
2026-05-29 20:08:46 +02:00
|
|
|
activeLineIndex: viewModel.state.activeLineIndex,
|
2026-05-30 19:24:48 +02:00
|
|
|
lineIndex: viewModel.state.lineIndex,
|
|
|
|
|
onTextEdit: viewModel.updateSource,
|
2026-05-29 20:08:46 +02:00
|
|
|
onRenderPass: viewModel.recordRenderPass
|
2026-05-29 17:55:37 +02:00
|
|
|
)
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
|
|
|
|
|
EditorStatusBar(
|
|
|
|
|
activeLineIndex: viewModel.state.activeLineIndex,
|
2026-05-29 19:19:59 +02:00
|
|
|
columnNumber: viewModel.state.activeColumnNumber,
|
2026-05-30 19:24:48 +02:00
|
|
|
lineCount: viewModel.state.lineCount,
|
2026-05-29 19:19:59 +02:00
|
|
|
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
|
2026-05-29 17:55:37 +02:00
|
|
|
)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
.background(platformTextBackground)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
private struct EditorStatusBar: View {
|
|
|
|
|
let activeLineIndex: Int
|
2026-05-29 19:19:59 +02:00
|
|
|
let columnNumber: Int
|
2026-05-29 17:55:37 +02:00
|
|
|
let lineCount: Int
|
|
|
|
|
let hasUnsavedChanges: Bool
|
2026-05-29 15:19:33 +02:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-05-29 17:55:37 +02:00
|
|
|
HStack(spacing: 12) {
|
2026-05-29 19:19:59 +02:00
|
|
|
Text("Line \(activeLineIndex + 1)")
|
|
|
|
|
Text("Column \(columnNumber)")
|
|
|
|
|
Text("\(lineCount) lines")
|
2026-05-29 17:55:37 +02:00
|
|
|
Text(hasUnsavedChanges ? "Modified" : "Saved")
|
|
|
|
|
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
|
|
|
|
|
Spacer()
|
|
|
|
|
}
|
2026-05-29 19:19:59 +02:00
|
|
|
.font(.caption.weight(.medium))
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.vertical, 7)
|
|
|
|
|
.background(.thinMaterial)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
private struct NativeMarkdownTextView: NSViewRepresentable {
|
|
|
|
|
@Binding var text: String
|
|
|
|
|
@Binding var selection: EditorSelection
|
|
|
|
|
let activeLineIndex: Int
|
2026-05-30 19:24:48 +02:00
|
|
|
let lineIndex: DocumentLineIndex
|
|
|
|
|
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
|
2026-05-29 20:08:46 +02:00
|
|
|
let onRenderPass: (EditorRenderPassMetric) -> Void
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
|
|
|
Coordinator(self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
2026-05-29 19:19:59 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
let textView = EditorTextView(frame: scrollView.contentView.bounds, textContainer: textContainer)
|
2026-05-29 19:19:59 +02:00
|
|
|
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
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
textView.delegate = context.coordinator
|
|
|
|
|
textView.string = text
|
|
|
|
|
textView.isRichText = false
|
2026-05-29 19:47:40 +02:00
|
|
|
textView.isEditable = true
|
|
|
|
|
textView.isSelectable = true
|
2026-05-29 17:55:37 +02:00
|
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
|
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
|
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
|
|
|
textView.allowsUndo = true
|
|
|
|
|
textView.usesFindPanel = true
|
|
|
|
|
textView.isContinuousSpellCheckingEnabled = true
|
|
|
|
|
textView.backgroundColor = .textBackgroundColor
|
|
|
|
|
textView.insertionPointColor = .controlAccentColor
|
2026-05-29 19:19:59 +02:00
|
|
|
textView.font = .systemFont(ofSize: 16, weight: .regular)
|
2026-05-29 17:55:37 +02:00
|
|
|
textView.textContainer?.widthTracksTextView = true
|
|
|
|
|
textView.textContainer?.containerSize = NSSize(
|
|
|
|
|
width: scrollView.contentSize.width,
|
|
|
|
|
height: CGFloat.greatestFiniteMagnitude
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-29 19:19:59 +02:00
|
|
|
scrollView.documentView = textView
|
|
|
|
|
scrollView.editorTextView = textView
|
|
|
|
|
scrollView.updateEditorInsets()
|
2026-05-29 17:55:37 +02:00
|
|
|
context.coordinator.applyHybridAttributes(to: textView)
|
2026-05-29 19:47:40 +02:00
|
|
|
context.coordinator.requestInitialFocus(for: textView)
|
2026-05-29 17:55:37 +02:00
|
|
|
return scrollView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
|
|
|
context.coordinator.parent = self
|
2026-05-30 19:24:48 +02:00
|
|
|
context.coordinator.currentLineIndex = lineIndex
|
2026-05-29 17:55:37 +02:00
|
|
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
|
|
|
|
|
|
|
|
|
if textView.string != text {
|
2026-05-29 19:02:51 +02:00
|
|
|
context.coordinator.performProgrammaticUpdate {
|
|
|
|
|
textView.string = text
|
|
|
|
|
}
|
2026-05-29 20:57:03 +02:00
|
|
|
context.coordinator.invalidateStylingCache()
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let selectedRange = selection.range
|
|
|
|
|
if textView.selectedRange() != selectedRange,
|
|
|
|
|
selectedRange.location <= textView.string.utf16.count {
|
2026-05-29 19:02:51 +02:00
|
|
|
context.coordinator.setSelection(selectedRange, in: textView)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.coordinator.applyHybridAttributes(to: textView)
|
2026-05-29 19:47:40 +02:00
|
|
|
context.coordinator.requestInitialFocus(for: textView)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
|
|
|
var parent: NativeMarkdownTextView
|
2026-05-30 19:24:48 +02:00
|
|
|
var currentLineIndex: DocumentLineIndex
|
2026-05-29 19:02:51 +02:00
|
|
|
private var programmaticUpdateDepth = 0
|
|
|
|
|
private var lastStyledText: String?
|
|
|
|
|
private var lastStyledActiveLineIndex: Int?
|
2026-05-30 19:24:48 +02:00
|
|
|
private var pendingEdit: DocumentLineIndexEdit?
|
2026-05-29 19:47:40 +02:00
|
|
|
private var didFocusTextView = false
|
2026-05-31 23:01:11 +02:00
|
|
|
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
init(_ parent: NativeMarkdownTextView) {
|
|
|
|
|
self.parent = parent
|
2026-05-30 19:24:48 +02:00
|
|
|
self.currentLineIndex = parent.lineIndex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textView(
|
|
|
|
|
_ textView: NSTextView,
|
|
|
|
|
shouldChangeTextIn affectedCharRange: NSRange,
|
|
|
|
|
replacementString: String?
|
|
|
|
|
) -> Bool {
|
|
|
|
|
pendingEdit = DocumentLineIndexEdit(
|
|
|
|
|
range: affectedCharRange,
|
|
|
|
|
replacement: replacementString ?? ""
|
|
|
|
|
)
|
|
|
|
|
return true
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textDidChange(_ notification: Notification) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard !isPerformingProgrammaticUpdate else { return }
|
|
|
|
|
guard let textView = notification.object as? NSTextView else { return }
|
2026-05-29 17:55:37 +02:00
|
|
|
|
2026-05-30 19:24:48 +02:00
|
|
|
let selection = EditorSelection(range: textView.selectedRange())
|
|
|
|
|
let edit = pendingEdit
|
|
|
|
|
if let edit {
|
2026-05-30 19:28:34 +02:00
|
|
|
currentLineIndex.replace(edit, updatedSource: textView.string)
|
2026-05-30 19:24:48 +02:00
|
|
|
} else {
|
|
|
|
|
currentLineIndex = DocumentLineIndex(source: textView.string)
|
|
|
|
|
}
|
|
|
|
|
parent.onTextEdit(textView.string, edit, selection)
|
|
|
|
|
parent.selection = selection
|
2026-05-29 17:55:37 +02:00
|
|
|
applyHybridAttributes(to: textView)
|
2026-05-30 19:24:48 +02:00
|
|
|
pendingEdit = nil
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidChangeSelection(_ notification: Notification) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard !isPerformingProgrammaticUpdate else { return }
|
2026-05-29 17:55:37 +02:00
|
|
|
guard let textView = notification.object as? NSTextView else { return }
|
2026-05-29 19:02:51 +02:00
|
|
|
let newSelection = EditorSelection(range: textView.selectedRange())
|
|
|
|
|
guard parent.selection != newSelection else { return }
|
2026-05-29 17:55:37 +02:00
|
|
|
applyHybridAttributes(to: textView)
|
2026-05-29 19:02:51 +02:00
|
|
|
parent.selection = newSelection
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func applyHybridAttributes(to textView: NSTextView) {
|
|
|
|
|
guard let textStorage = textView.textStorage else { return }
|
2026-05-31 23:01:11 +02:00
|
|
|
let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
|
|
|
|
let invalidationPlan = invalidationPlan(for: textView.string, activeLineIndex: activeLineIndex)
|
2026-05-29 20:57:03 +02:00
|
|
|
guard invalidationPlan.requiresStyling else { return }
|
2026-05-29 19:02:51 +02:00
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
let selectedRange = textView.selectedRange()
|
2026-05-29 20:08:46 +02:00
|
|
|
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
|
|
|
|
let start = Date()
|
2026-05-29 20:57:03 +02:00
|
|
|
var stylingResult = MarkdownTextStylingResult.empty
|
|
|
|
|
var didRestoreVisibleOrigin = false
|
2026-05-29 19:02:51 +02:00
|
|
|
performProgrammaticUpdate {
|
2026-05-29 20:57:03 +02:00
|
|
|
stylingResult = MarkdownTextStyler.apply(
|
2026-05-29 19:02:51 +02:00
|
|
|
to: textStorage,
|
2026-05-30 19:24:48 +02:00
|
|
|
lineIndex: currentLineIndex,
|
2026-05-29 20:57:03 +02:00
|
|
|
invalidationPlan: invalidationPlan,
|
2026-05-31 23:01:11 +02:00
|
|
|
activeLineIndex: activeLineIndex,
|
2026-05-29 19:02:51 +02:00
|
|
|
backgroundColor: .textBackgroundColor,
|
|
|
|
|
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
|
|
|
|
|
textColor: .labelColor,
|
|
|
|
|
secondaryTextColor: .secondaryLabelColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
accentColor: .controlAccentColor,
|
|
|
|
|
usesRenderedControls: true
|
2026-05-29 19:02:51 +02:00
|
|
|
)
|
|
|
|
|
if textView.selectedRange() != selectedRange,
|
|
|
|
|
selectedRange.location <= textView.string.utf16.count {
|
|
|
|
|
textView.setSelectedRange(selectedRange)
|
|
|
|
|
}
|
2026-05-29 20:57:03 +02:00
|
|
|
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastStyledText = textView.string
|
2026-05-31 23:01:11 +02:00
|
|
|
lastStyledActiveLineIndex = activeLineIndex
|
|
|
|
|
syncChecklistControls(
|
|
|
|
|
in: textView,
|
|
|
|
|
stylingResult: stylingResult,
|
|
|
|
|
invalidationPlan: invalidationPlan,
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
2026-05-29 20:08:46 +02:00
|
|
|
parent.onRenderPass(EditorRenderPassMetric(
|
2026-05-29 20:57:03 +02:00
|
|
|
reason: invalidationPlan.reason,
|
2026-05-29 20:08:46 +02:00
|
|
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
|
|
|
|
characterCount: textView.string.utf16.count,
|
2026-05-29 20:57:03 +02:00
|
|
|
lineCount: stylingResult.totalLineCount,
|
|
|
|
|
dirtyLineCount: stylingResult.styledLineCount,
|
2026-05-31 23:01:11 +02:00
|
|
|
activeLineIndex: activeLineIndex,
|
2026-05-29 20:57:03 +02:00
|
|
|
isFullRender: invalidationPlan.isFullRender,
|
|
|
|
|
restoredScrollPosition: didRestoreVisibleOrigin
|
2026-05-29 20:08:46 +02:00
|
|
|
))
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
|
|
|
|
guard textView.selectedRange() != range else { return }
|
|
|
|
|
performProgrammaticUpdate {
|
|
|
|
|
textView.setSelectedRange(range)
|
2026-05-29 19:19:59 +02:00
|
|
|
textView.scrollRangeToVisible(range)
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:02:51 +02:00
|
|
|
func performProgrammaticUpdate(_ updates: () -> Void) {
|
|
|
|
|
programmaticUpdateDepth += 1
|
|
|
|
|
defer {
|
|
|
|
|
programmaticUpdateDepth -= 1
|
|
|
|
|
}
|
|
|
|
|
updates()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
func invalidateStylingCache() {
|
|
|
|
|
lastStyledText = nil
|
|
|
|
|
lastStyledActiveLineIndex = nil
|
2026-05-31 23:01:11 +02:00
|
|
|
removeChecklistControls()
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
private var isPerformingProgrammaticUpdate: Bool {
|
|
|
|
|
programmaticUpdateDepth > 0
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
2026-05-29 20:57:03 +02:00
|
|
|
EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: lastStyledText,
|
2026-05-30 19:24:48 +02:00
|
|
|
currentLineIndex: currentLineIndex,
|
|
|
|
|
edit: pendingEdit,
|
2026-05-29 20:57:03 +02:00
|
|
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
2026-05-31 23:01:11 +02:00
|
|
|
currentActiveLineIndex: activeLineIndex
|
2026-05-29 20:57:03 +02:00
|
|
|
)
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
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 }
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
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
|
2026-05-31 23:01:11 +02:00
|
|
|
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
|
2026-06-01 09:13:09 +02:00
|
|
|
pendingEdit = previousPendingEdit
|
2026-05-31 23:01:11 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-31 23:01:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
|
2026-05-29 20:08:46 +02:00
|
|
|
guard let origin,
|
|
|
|
|
let scrollView = textView.enclosingScrollView
|
2026-05-29 20:57:03 +02:00
|
|
|
else { return false }
|
2026-05-29 20:08:46 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-05-29 20:57:03 +02:00
|
|
|
return true
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 19:19:59 +02:00
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
private final class EditorTextView: NSTextView {
|
|
|
|
|
override var acceptsFirstResponder: Bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
private final class ChecklistOverlayButton: NSButton {
|
|
|
|
|
var task: RenderedTaskElement?
|
|
|
|
|
var onToggle: (() -> Void)?
|
|
|
|
|
|
|
|
|
|
init() {
|
|
|
|
|
super.init(frame: .zero)
|
|
|
|
|
setButtonType(.switch)
|
|
|
|
|
title = ""
|
|
|
|
|
isBordered = false
|
|
|
|
|
imagePosition = .imageOnly
|
2026-06-01 09:13:09 +02:00
|
|
|
cell?.refusesFirstResponder = true
|
2026-05-31 23:01:11 +02:00
|
|
|
target = self
|
|
|
|
|
action = #selector(toggleCheckbox)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:13:09 +02:00
|
|
|
override var acceptsFirstResponder: Bool {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
@available(*, unavailable)
|
|
|
|
|
required init?(coder: NSCoder) {
|
|
|
|
|
nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func toggleCheckbox() {
|
|
|
|
|
onToggle?()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:19:59 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
#elseif os(iOS)
|
|
|
|
|
private struct NativeMarkdownTextView: UIViewRepresentable {
|
|
|
|
|
@Binding var text: String
|
|
|
|
|
@Binding var selection: EditorSelection
|
|
|
|
|
let activeLineIndex: Int
|
2026-05-30 19:24:48 +02:00
|
|
|
let lineIndex: DocumentLineIndex
|
|
|
|
|
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
|
2026-05-29 20:08:46 +02:00
|
|
|
let onRenderPass: (EditorRenderPassMetric) -> Void
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
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
|
2026-05-29 19:19:59 +02:00
|
|
|
textView.font = .systemFont(ofSize: 17, weight: .regular)
|
|
|
|
|
textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
|
2026-05-29 17:55:37 +02:00
|
|
|
textView.backgroundColor = .systemBackground
|
|
|
|
|
context.coordinator.applyHybridAttributes(to: textView)
|
2026-05-29 19:47:40 +02:00
|
|
|
context.coordinator.requestInitialFocus(for: textView)
|
2026-05-29 17:55:37 +02:00
|
|
|
return textView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateUIView(_ textView: UITextView, context: Context) {
|
|
|
|
|
context.coordinator.parent = self
|
2026-05-30 19:24:48 +02:00
|
|
|
context.coordinator.currentLineIndex = lineIndex
|
2026-05-29 17:55:37 +02:00
|
|
|
if textView.text != text {
|
2026-05-29 19:02:51 +02:00
|
|
|
context.coordinator.performProgrammaticUpdate {
|
|
|
|
|
textView.text = text
|
|
|
|
|
}
|
2026-05-29 20:57:03 +02:00
|
|
|
context.coordinator.invalidateStylingCache()
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
context.coordinator.applyHybridAttributes(to: textView)
|
2026-05-29 19:47:40 +02:00
|
|
|
context.coordinator.requestInitialFocus(for: textView)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
|
|
|
var parent: NativeMarkdownTextView
|
2026-05-30 19:24:48 +02:00
|
|
|
var currentLineIndex: DocumentLineIndex
|
2026-05-29 19:02:51 +02:00
|
|
|
private var programmaticUpdateDepth = 0
|
|
|
|
|
private var lastStyledText: String?
|
|
|
|
|
private var lastStyledActiveLineIndex: Int?
|
2026-05-30 19:24:48 +02:00
|
|
|
private var pendingEdit: DocumentLineIndexEdit?
|
2026-05-29 19:47:40 +02:00
|
|
|
private var didFocusTextView = false
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
init(_ parent: NativeMarkdownTextView) {
|
|
|
|
|
self.parent = parent
|
2026-05-30 19:24:48 +02:00
|
|
|
self.currentLineIndex = parent.lineIndex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textView(
|
|
|
|
|
_ textView: UITextView,
|
|
|
|
|
shouldChangeTextIn range: NSRange,
|
|
|
|
|
replacementText text: String
|
|
|
|
|
) -> Bool {
|
|
|
|
|
pendingEdit = DocumentLineIndexEdit(range: range, replacement: text)
|
|
|
|
|
return true
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard !isPerformingProgrammaticUpdate else { return }
|
2026-05-30 19:24:48 +02:00
|
|
|
let selection = EditorSelection(range: textView.selectedRange)
|
|
|
|
|
let edit = pendingEdit
|
|
|
|
|
if let edit {
|
2026-05-30 19:28:34 +02:00
|
|
|
currentLineIndex.replace(edit, updatedSource: textView.text)
|
2026-05-30 19:24:48 +02:00
|
|
|
} else {
|
|
|
|
|
currentLineIndex = DocumentLineIndex(source: textView.text)
|
|
|
|
|
}
|
|
|
|
|
parent.onTextEdit(textView.text, edit, selection)
|
|
|
|
|
parent.selection = selection
|
2026-05-29 17:55:37 +02:00
|
|
|
applyHybridAttributes(to: textView)
|
2026-05-30 19:24:48 +02:00
|
|
|
pendingEdit = nil
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
2026-05-29 19:02:51 +02:00
|
|
|
guard !isPerformingProgrammaticUpdate else { return }
|
|
|
|
|
let newSelection = EditorSelection(range: textView.selectedRange)
|
|
|
|
|
guard parent.selection != newSelection else { return }
|
2026-05-29 17:55:37 +02:00
|
|
|
applyHybridAttributes(to: textView)
|
2026-05-29 19:02:51 +02:00
|
|
|
parent.selection = newSelection
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func applyHybridAttributes(to textView: UITextView) {
|
2026-05-31 23:01:11 +02:00
|
|
|
let activeLineIndex = currentLineIndex.lineIndex(containing: textView.selectedRange.location)
|
|
|
|
|
let invalidationPlan = invalidationPlan(for: textView.text, activeLineIndex: activeLineIndex)
|
2026-05-29 20:57:03 +02:00
|
|
|
guard invalidationPlan.requiresStyling else { return }
|
2026-05-29 19:02:51 +02:00
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
let selectedRange = textView.selectedRange
|
2026-05-29 20:08:46 +02:00
|
|
|
let contentOffset = textView.contentOffset
|
|
|
|
|
let start = Date()
|
2026-05-29 20:57:03 +02:00
|
|
|
var stylingResult = MarkdownTextStylingResult.empty
|
|
|
|
|
var didRestoreContentOffset = false
|
2026-05-29 19:02:51 +02:00
|
|
|
performProgrammaticUpdate {
|
2026-05-29 20:57:03 +02:00
|
|
|
stylingResult = MarkdownTextStyler.apply(
|
2026-05-29 19:02:51 +02:00
|
|
|
to: textView.textStorage,
|
2026-05-30 19:24:48 +02:00
|
|
|
lineIndex: currentLineIndex,
|
2026-05-29 20:57:03 +02:00
|
|
|
invalidationPlan: invalidationPlan,
|
2026-05-31 23:01:11 +02:00
|
|
|
activeLineIndex: activeLineIndex,
|
2026-05-29 19:02:51 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
|
2026-05-29 20:57:03 +02:00
|
|
|
didRestoreContentOffset = true
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastStyledText = textView.text
|
2026-05-31 23:01:11 +02:00
|
|
|
lastStyledActiveLineIndex = activeLineIndex
|
2026-05-29 20:08:46 +02:00
|
|
|
parent.onRenderPass(EditorRenderPassMetric(
|
2026-05-29 20:57:03 +02:00
|
|
|
reason: invalidationPlan.reason,
|
2026-05-29 20:08:46 +02:00
|
|
|
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
|
|
|
|
|
characterCount: textView.text.utf16.count,
|
2026-05-29 20:57:03 +02:00
|
|
|
lineCount: stylingResult.totalLineCount,
|
|
|
|
|
dirtyLineCount: stylingResult.styledLineCount,
|
2026-05-31 23:01:11 +02:00
|
|
|
activeLineIndex: activeLineIndex,
|
2026-05-29 20:57:03 +02:00
|
|
|
isFullRender: invalidationPlan.isFullRender,
|
|
|
|
|
restoredScrollPosition: didRestoreContentOffset
|
2026-05-29 20:08:46 +02:00
|
|
|
))
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func performProgrammaticUpdate(_ updates: () -> Void) {
|
|
|
|
|
programmaticUpdateDepth += 1
|
|
|
|
|
defer {
|
|
|
|
|
programmaticUpdateDepth -= 1
|
|
|
|
|
}
|
|
|
|
|
updates()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
func invalidateStylingCache() {
|
|
|
|
|
lastStyledText = nil
|
|
|
|
|
lastStyledActiveLineIndex = nil
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
private var isPerformingProgrammaticUpdate: Bool {
|
|
|
|
|
programmaticUpdateDepth > 0
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
private func invalidationPlan(for text: String, activeLineIndex: Int) -> EditorDirtyLineInvalidationPlan {
|
2026-05-29 20:57:03 +02:00
|
|
|
EditorDirtyLineInvalidator.plan(
|
|
|
|
|
previousText: lastStyledText,
|
2026-05-30 19:24:48 +02:00
|
|
|
currentLineIndex: currentLineIndex,
|
|
|
|
|
edit: pendingEdit,
|
2026-05-29 20:57:03 +02:00
|
|
|
previousActiveLineIndex: lastStyledActiveLineIndex,
|
2026-05-31 23:01:11 +02:00
|
|
|
currentActiveLineIndex: activeLineIndex
|
2026-05-29 20:57:03 +02:00
|
|
|
)
|
2026-05-29 20:08:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)))
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-05-30 17:57:37 +02:00
|
|
|
struct MarkdownTextStylingResult {
|
2026-05-29 20:57:03 +02:00
|
|
|
var totalLineCount: Int
|
|
|
|
|
var styledLineCount: Int
|
2026-05-31 23:01:11 +02:00
|
|
|
var styledLineIndexes: [Int]
|
|
|
|
|
var renderedTasks: [RenderedTaskElement]
|
|
|
|
|
|
|
|
|
|
static let empty = MarkdownTextStylingResult(
|
|
|
|
|
totalLineCount: 0,
|
|
|
|
|
styledLineCount: 0,
|
|
|
|
|
styledLineIndexes: [],
|
|
|
|
|
renderedTasks: []
|
|
|
|
|
)
|
2026-05-29 20:57:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-30 17:57:37 +02:00
|
|
|
enum MarkdownTextStyler {
|
2026-05-29 17:55:37 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
typealias PlatformColor = NSColor
|
|
|
|
|
#elseif os(iOS)
|
|
|
|
|
typealias PlatformColor = UIColor
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
@discardableResult
|
2026-05-29 17:55:37 +02:00
|
|
|
static func apply(
|
|
|
|
|
to textStorage: NSTextStorage,
|
2026-05-30 19:24:48 +02:00
|
|
|
lineIndex: DocumentLineIndex,
|
2026-05-29 20:57:03 +02:00
|
|
|
invalidationPlan: EditorDirtyLineInvalidationPlan,
|
2026-05-29 17:55:37 +02:00
|
|
|
activeLineIndex: Int,
|
|
|
|
|
backgroundColor: PlatformColor,
|
|
|
|
|
activeLineBackgroundColor: PlatformColor,
|
|
|
|
|
textColor: PlatformColor,
|
|
|
|
|
secondaryTextColor: PlatformColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
accentColor: PlatformColor,
|
|
|
|
|
usesRenderedControls: Bool = false
|
2026-05-29 20:57:03 +02:00
|
|
|
) -> MarkdownTextStylingResult {
|
2026-05-30 19:24:48 +02:00
|
|
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
2026-05-29 20:57:03 +02:00
|
|
|
guard fullRange.length > 0 else {
|
2026-05-31 23:01:11 +02:00
|
|
|
return MarkdownTextStylingResult(
|
|
|
|
|
totalLineCount: lineIndex.lineCount,
|
|
|
|
|
styledLineCount: lineIndex.lineCount,
|
|
|
|
|
styledLineIndexes: Array(0..<lineIndex.lineCount),
|
|
|
|
|
renderedTasks: []
|
|
|
|
|
)
|
2026-05-29 20:57:03 +02:00
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
|
|
|
|
|
textStorage.beginEditing()
|
2026-05-29 20:57:03 +02:00
|
|
|
if invalidationPlan.isFullRender {
|
|
|
|
|
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
let presentationState = DocumentPresentationState(
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
activeLineIndex: activeLineIndex,
|
|
|
|
|
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
|
|
|
|
|
)
|
2026-05-29 20:57:03 +02:00
|
|
|
var styledLineCount = 0
|
2026-05-31 23:01:11 +02:00
|
|
|
var styledLineIndexes: [Int] = []
|
|
|
|
|
for presentationLine in presentationState.lines {
|
|
|
|
|
let line = presentationLine.line
|
2026-05-29 20:57:03 +02:00
|
|
|
resetAttributes(in: textStorage, line: line, textColor: textColor)
|
|
|
|
|
styledLineCount += 1
|
2026-05-31 23:01:11 +02:00
|
|
|
styledLineIndexes.append(line.index)
|
2026-05-29 20:57:03 +02:00
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
switch presentationLine.state {
|
|
|
|
|
case .source:
|
2026-05-29 17:55:37 +02:00
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.backgroundColor: activeLineBackgroundColor,
|
2026-05-29 19:19:59 +02:00
|
|
|
.font: monospacedFont(size: 15, weight: .regular)
|
2026-05-29 17:55:37 +02:00
|
|
|
], range: line.range)
|
|
|
|
|
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
|
2026-05-31 23:01:11 +02:00
|
|
|
case .rendered:
|
2026-05-29 17:55:37 +02:00
|
|
|
styleRenderedLine(
|
|
|
|
|
in: textStorage,
|
|
|
|
|
line: line,
|
2026-05-31 23:01:11 +02:00
|
|
|
renderPlan: presentationLine.renderPlan,
|
2026-05-31 20:47:34 +02:00
|
|
|
textColor: textColor,
|
|
|
|
|
backgroundColor: backgroundColor,
|
2026-05-29 17:55:37 +02:00
|
|
|
secondaryTextColor: secondaryTextColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
accentColor: accentColor,
|
|
|
|
|
usesRenderedControls: usesRenderedControls
|
2026-05-29 17:55:37 +02:00
|
|
|
)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textStorage.endEditing()
|
2026-05-31 23:01:11 +02:00
|
|
|
return MarkdownTextStylingResult(
|
|
|
|
|
totalLineCount: presentationState.lineCount,
|
|
|
|
|
styledLineCount: styledLineCount,
|
|
|
|
|
styledLineIndexes: styledLineIndexes,
|
|
|
|
|
renderedTasks: presentationState.renderedTasks
|
|
|
|
|
)
|
2026-05-31 22:21:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
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)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func styleRenderedLine(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
line: EditorLine,
|
2026-05-29 20:08:46 +02:00
|
|
|
renderPlan: HybridMarkdownLineRenderPlan,
|
2026-05-31 20:47:34 +02:00
|
|
|
textColor: PlatformColor,
|
|
|
|
|
backgroundColor: PlatformColor,
|
2026-05-29 17:55:37 +02:00
|
|
|
secondaryTextColor: PlatformColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
accentColor: PlatformColor,
|
|
|
|
|
usesRenderedControls: Bool
|
2026-05-29 17:55:37 +02:00
|
|
|
) {
|
|
|
|
|
guard line.range.length > 0 else { return }
|
|
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
switch renderPlan.kind {
|
|
|
|
|
case .heading(let level, let markerRange, let textRange):
|
2026-05-31 22:21:03 +02:00
|
|
|
hideSyntax(
|
|
|
|
|
in: textStorage,
|
|
|
|
|
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
|
|
|
|
|
)
|
2026-05-29 17:55:37 +02:00
|
|
|
textStorage.addAttributes([
|
2026-05-31 20:47:34 +02:00
|
|
|
.font: systemFont(size: headingFontSize(level: level), weight: .semibold),
|
|
|
|
|
.paragraphStyle: headingParagraphStyle(level: level)
|
2026-05-29 20:08:46 +02:00
|
|
|
], range: textRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
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):
|
2026-05-31 22:21:03 +02:00
|
|
|
styleTaskListLine(
|
2026-05-31 20:47:34 +02:00
|
|
|
in: textStorage,
|
|
|
|
|
lineRange: line.range,
|
|
|
|
|
markerRange: markerRange,
|
2026-05-31 22:21:03 +02:00
|
|
|
checkboxRange: checkboxRange,
|
2026-05-31 20:47:34 +02:00
|
|
|
contentRange: contentRange,
|
2026-05-31 22:21:03 +02:00
|
|
|
checked: checked,
|
2026-05-31 20:47:34 +02:00
|
|
|
nestingLevel: nestingLevel,
|
2026-05-31 22:21:03 +02:00
|
|
|
secondaryTextColor: secondaryTextColor,
|
|
|
|
|
accentColor: accentColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
backgroundColor: backgroundColor,
|
|
|
|
|
usesRenderedControls: usesRenderedControls
|
2026-05-31 20:47:34 +02:00
|
|
|
)
|
|
|
|
|
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 {
|
2026-05-31 22:21:03 +02:00
|
|
|
hideSyntax(
|
|
|
|
|
in: textStorage,
|
|
|
|
|
range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location)
|
|
|
|
|
)
|
2026-05-31 20:47:34 +02:00
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: accentColor,
|
|
|
|
|
.font: monospacedFont(size: 13, weight: .semibold)
|
|
|
|
|
], range: languageRange)
|
2026-05-31 22:21:03 +02:00
|
|
|
} else {
|
|
|
|
|
hideSyntax(in: textStorage, range: line.range)
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
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
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
styleInlineSpans(
|
2026-05-29 17:55:37 +02:00
|
|
|
in: textStorage,
|
2026-05-29 20:08:46 +02:00
|
|
|
renderPlan: renderPlan,
|
2026-05-29 17:55:37 +02:00
|
|
|
secondaryTextColor: secondaryTextColor,
|
|
|
|
|
accentColor: accentColor
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:08:46 +02:00
|
|
|
private static func styleInlineSpans(
|
2026-05-29 17:55:37 +02:00
|
|
|
in textStorage: NSTextStorage,
|
2026-05-29 20:08:46 +02:00
|
|
|
renderPlan: HybridMarkdownLineRenderPlan,
|
2026-05-29 17:55:37 +02:00
|
|
|
secondaryTextColor: PlatformColor,
|
|
|
|
|
accentColor: PlatformColor
|
|
|
|
|
) {
|
2026-05-29 20:08:46 +02:00
|
|
|
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)
|
2026-05-31 20:47:34 +02:00
|
|
|
case .link, .automaticLink:
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: accentColor,
|
|
|
|
|
.underlineStyle: NSUnderlineStyle.single.rawValue
|
|
|
|
|
], range: span.range)
|
2026-05-29 20:08:46 +02:00
|
|
|
case .markdownDelimiter:
|
2026-05-31 22:21:03 +02:00
|
|
|
hideSyntax(in: textStorage, range: span.range)
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
private static func styleSourceLineMarkers(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
line: EditorLine,
|
|
|
|
|
secondaryTextColor: PlatformColor
|
|
|
|
|
) {
|
2026-05-31 20:47:34 +02:00
|
|
|
applyRegex("(#{1,6}|\\*\\*|__|\\*|_|`|\\[[ xX]\\]|\\[|\\]|\\(|\\)|\\||>|-{3,}|[-*]|\\d+[.)])", in: textStorage, line: line) { match in
|
2026-05-29 17:55:37 +02:00
|
|
|
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
|
|
|
|
|
let paragraph = NSMutableParagraphStyle()
|
|
|
|
|
paragraph.lineSpacing = 4
|
2026-05-29 19:19:59 +02:00
|
|
|
paragraph.paragraphSpacing = 5
|
2026-05-29 17:55:37 +02:00
|
|
|
return [
|
2026-05-29 19:19:59 +02:00
|
|
|
.font: systemFont(size: 16, weight: .regular),
|
2026-05-29 17:55:37 +02:00
|
|
|
.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 {
|
2026-05-29 15:19:33 +02:00
|
|
|
switch level {
|
2026-05-29 19:19:59 +02:00
|
|
|
case 1: 28
|
|
|
|
|
case 2: 23
|
|
|
|
|
case 3: 20
|
|
|
|
|
default: 17
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
|
2026-05-31 20:47:34 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 22:21:03 +02:00
|
|
|
private static func styleTaskListLine(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
lineRange: NSRange,
|
|
|
|
|
markerRange: NSRange,
|
|
|
|
|
checkboxRange: NSRange,
|
|
|
|
|
contentRange: NSRange,
|
|
|
|
|
checked: Bool,
|
|
|
|
|
nestingLevel: Int,
|
|
|
|
|
secondaryTextColor: PlatformColor,
|
|
|
|
|
accentColor: PlatformColor,
|
2026-05-31 23:01:11 +02:00
|
|
|
backgroundColor: PlatformColor,
|
|
|
|
|
usesRenderedControls: Bool
|
2026-05-31 22:21:03 +02:00
|
|
|
) {
|
|
|
|
|
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)
|
2026-05-31 23:01:11 +02:00
|
|
|
if usesRenderedControls {
|
|
|
|
|
hideSyntax(in: textStorage, range: checkboxRange)
|
|
|
|
|
}
|
2026-05-31 22:21:03 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
#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)
|
|
|
|
|
}
|
2026-05-31 22:21:03 +02:00
|
|
|
|
|
|
|
|
private static func clearColor() -> NSColor {
|
|
|
|
|
.clear
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
#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)
|
|
|
|
|
}
|
2026-05-31 22:21:03 +02:00
|
|
|
|
|
|
|
|
private static func clearColor() -> UIColor {
|
|
|
|
|
.clear
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension NSRange {
|
|
|
|
|
var upperBound: Int {
|
|
|
|
|
location + length
|
|
|
|
|
}
|
2026-05-29 15:19:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var platformTextBackground: Color {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
Color(nsColor: .textBackgroundColor)
|
|
|
|
|
#else
|
|
|
|
|
Color(uiColor: .systemBackground)
|
|
|
|
|
#endif
|
|
|
|
|
}
|