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
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
textView.string = text
|
2026-05-29 17:55:37 +02:00
|
|
|
textView.delegate = context.coordinator
|
2026-06-01 09:27:22 +02:00
|
|
|
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
|
|
|
|
|
coordinator?.applyHybridAttributes(to: textView)
|
|
|
|
|
}
|
2026-06-01 09:40:19 +02:00
|
|
|
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in
|
|
|
|
|
coordinator?.activateEditingPresentation(in: textView)
|
|
|
|
|
}
|
2026-05-29 17:55:37 +02:00
|
|
|
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
|
2026-06-01 09:56:36 +02:00
|
|
|
scrollView.onEditorLayoutInvalidated = { [weak coordinator = context.coordinator] textView in
|
|
|
|
|
coordinator?.syncChecklistControlFrames(in: textView)
|
2026-06-01 14:22:27 +02:00
|
|
|
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
|
2026-06-01 09:56:36 +02:00
|
|
|
}
|
2026-05-29 19:19:59 +02:00
|
|
|
scrollView.updateEditorInsets()
|
2026-05-29 17:55:37 +02:00
|
|
|
context.coordinator.applyHybridAttributes(to: textView)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
private var lastStyledEditableRegion: EditableRegion?
|
2026-05-30 19:24:48 +02:00
|
|
|
private var pendingEdit: DocumentLineIndexEdit?
|
2026-06-01 09:40:19 +02:00
|
|
|
private var hasUserActivatedEditing = 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-06-01 09:40:19 +02:00
|
|
|
hasUserActivatedEditing = true
|
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-06-01 10:17:17 +02:00
|
|
|
let editableRegion = presentationEditableRegion(in: textView)
|
|
|
|
|
let activeLineIndex = editableRegion.primaryLineIndex
|
|
|
|
|
let invalidationPlan = invalidationPlan(for: textView.string, editableRegion: editableRegion)
|
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-06-01 14:44:14 +02:00
|
|
|
let viewportAnchor = ViewportPresentationAnchor.capture(
|
|
|
|
|
in: textView,
|
|
|
|
|
selectedRange: selectedRange,
|
|
|
|
|
lineIndex: currentLineIndex
|
|
|
|
|
)
|
2026-05-29 20:08:46 +02:00
|
|
|
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,
|
2026-06-01 10:17:17 +02:00
|
|
|
usesRenderedControls: true,
|
|
|
|
|
editableRegion: editableRegion
|
2026-05-29 19:02:51 +02:00
|
|
|
)
|
|
|
|
|
if textView.selectedRange() != selectedRange,
|
|
|
|
|
selectedRange.location <= textView.string.utf16.count {
|
|
|
|
|
textView.setSelectedRange(selectedRange)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 14:44:14 +02:00
|
|
|
didRestoreVisibleOrigin = restoreViewportAnchor(viewportAnchor, in: textView)
|
2026-05-29 19:02:51 +02:00
|
|
|
|
|
|
|
|
lastStyledText = textView.string
|
2026-05-31 23:01:11 +02:00
|
|
|
lastStyledActiveLineIndex = activeLineIndex
|
2026-06-01 10:17:17 +02:00
|
|
|
lastStyledEditableRegion = editableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
syncChecklistControls(
|
|
|
|
|
in: textView,
|
|
|
|
|
stylingResult: stylingResult,
|
|
|
|
|
invalidationPlan: invalidationPlan,
|
|
|
|
|
activeLineIndex: activeLineIndex
|
|
|
|
|
)
|
2026-06-01 14:22:27 +02:00
|
|
|
syncCodeBlockContainers(in: textView, editableRegion: editableRegion)
|
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
|
|
|
}
|
|
|
|
|
|
2026-06-01 10:17:17 +02:00
|
|
|
private func presentationEditableRegion(in textView: NSTextView) -> EditableRegion {
|
2026-06-01 09:40:19 +02:00
|
|
|
guard hasUserActivatedEditing,
|
|
|
|
|
textView.window?.firstResponder === textView
|
2026-06-01 10:17:17 +02:00
|
|
|
else { return .none() }
|
|
|
|
|
return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex)
|
2026-06-01 09:27:22 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
func activateEditingPresentation(in textView: NSTextView) {
|
|
|
|
|
guard !hasUserActivatedEditing else { return }
|
|
|
|
|
hasUserActivatedEditing = true
|
|
|
|
|
applyHybridAttributes(to: textView)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:02:51 +02:00
|
|
|
func setSelection(_ range: NSRange, in textView: NSTextView) {
|
|
|
|
|
guard textView.selectedRange() != range else { return }
|
|
|
|
|
performProgrammaticUpdate {
|
2026-06-01 14:44:14 +02:00
|
|
|
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
|
|
|
|
|
let shouldScroll = selectionNeedsScroll(range, in: textView)
|
2026-05-29 19:02:51 +02:00
|
|
|
textView.setSelectedRange(range)
|
2026-06-01 14:44:14 +02:00
|
|
|
if shouldScroll {
|
|
|
|
|
textView.scrollRangeToVisible(range)
|
|
|
|
|
} else if let visibleOrigin {
|
|
|
|
|
_ = scrollVisibleOrigin(visibleOrigin, in: textView)
|
|
|
|
|
}
|
2026-05-29 19:02:51 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
private func selectionNeedsScroll(_ range: NSRange, in textView: NSTextView) -> Bool {
|
|
|
|
|
guard let point = textView.presentationAnchorPoint(at: range.location),
|
|
|
|
|
let scrollView = textView.enclosingScrollView
|
|
|
|
|
else { return true }
|
|
|
|
|
|
|
|
|
|
let visibleRect = scrollView.contentView.bounds.insetBy(dx: 0, dy: 12)
|
|
|
|
|
return !visibleRect.contains(point)
|
|
|
|
|
}
|
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
lastStyledEditableRegion = nil
|
2026-06-01 09:40:19 +02:00
|
|
|
hasUserActivatedEditing = false
|
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-06-01 10:17:17 +02:00
|
|
|
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
|
|
|
|
|
let previousEditableRegion = lastStyledEditableRegion
|
|
|
|
|
let plan = EditorDirtyLineInvalidator.plan(
|
2026-05-29 20:57:03 +02:00
|
|
|
previousText: lastStyledText,
|
2026-05-30 19:24:48 +02:00
|
|
|
currentLineIndex: currentLineIndex,
|
|
|
|
|
edit: pendingEdit,
|
2026-06-01 10:17:17 +02:00
|
|
|
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
|
|
|
|
|
currentActiveLineIndex: editableRegion.primaryLineIndex
|
|
|
|
|
)
|
|
|
|
|
return plan.includingEditableRegionTransition(
|
|
|
|
|
from: previousEditableRegion,
|
|
|
|
|
to: editableRegion,
|
|
|
|
|
lineCount: currentLineIndex.lineCount
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
syncChecklistControlFrames(in: textView)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func syncChecklistControlFrames(in textView: NSTextView) {
|
|
|
|
|
guard !checklistButtons.isEmpty else { return }
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:22:27 +02:00
|
|
|
private func syncCodeBlockContainers(in textView: NSTextView, editableRegion: EditableRegion) {
|
|
|
|
|
guard let textView = textView as? EditorTextView else { return }
|
|
|
|
|
let codeBlocks = DocumentPresentationState.renderedCodeBlocks(
|
|
|
|
|
in: currentLineIndex,
|
|
|
|
|
editableRegion: editableRegion
|
|
|
|
|
)
|
|
|
|
|
textView.codeBlockContainers = codeBlocks.map {
|
|
|
|
|
CodeBlockContainerPresentation(
|
|
|
|
|
codeBlock: $0,
|
|
|
|
|
languageLabel: codeBlockLanguageLabel(for: $0, in: textView.string)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func codeBlockLanguageLabel(
|
|
|
|
|
for codeBlock: RenderedCodeBlockElement,
|
|
|
|
|
in source: String
|
|
|
|
|
) -> String {
|
|
|
|
|
guard let languageRange = codeBlock.languageRange,
|
|
|
|
|
languageRange.upperBound <= source.utf16.count
|
|
|
|
|
else { return "Text" }
|
|
|
|
|
|
|
|
|
|
let language = (source as NSString).substring(with: languageRange)
|
|
|
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !language.isEmpty else { return "Text" }
|
|
|
|
|
return language.prefix(1).uppercased() + language.dropFirst().lowercased()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 23:01:11 +02:00
|
|
|
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
|
2026-06-01 10:57:08 +02:00
|
|
|
invalidateStylingCache()
|
2026-06-01 09:13:09 +02:00
|
|
|
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)
|
2026-06-01 09:56:36 +02:00
|
|
|
layoutManager.ensureLayout(forCharacterRange: characterRange)
|
2026-05-31 23:01:11 +02:00
|
|
|
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-06-01 14:44:14 +02:00
|
|
|
private func restoreViewportAnchor(
|
|
|
|
|
_ anchor: ViewportPresentationAnchor?,
|
|
|
|
|
in textView: NSTextView
|
|
|
|
|
) -> Bool {
|
|
|
|
|
guard let anchor,
|
|
|
|
|
let point = textView.presentationAnchorPoint(at: anchor.characterLocation)
|
|
|
|
|
else {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return scrollVisibleOrigin(
|
|
|
|
|
NSPoint(
|
|
|
|
|
x: anchor.visibleOrigin.x,
|
|
|
|
|
y: point.y - anchor.viewportOffsetY
|
|
|
|
|
),
|
|
|
|
|
in: textView
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-29 20:08:46 +02:00
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
private func scrollVisibleOrigin(_ origin: NSPoint, in textView: NSTextView) -> Bool {
|
|
|
|
|
guard let scrollView = textView.enclosingScrollView 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-06-01 14:22:27 +02:00
|
|
|
private struct CodeBlockContainerPresentation: Equatable {
|
|
|
|
|
var codeBlock: RenderedCodeBlockElement
|
|
|
|
|
var languageLabel: String
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
private struct ViewportPresentationAnchor {
|
|
|
|
|
var characterLocation: Int
|
|
|
|
|
var visibleOrigin: NSPoint
|
|
|
|
|
var viewportOffsetY: CGFloat
|
|
|
|
|
|
|
|
|
|
static func capture(
|
|
|
|
|
in textView: NSTextView,
|
|
|
|
|
selectedRange: NSRange,
|
|
|
|
|
lineIndex: DocumentLineIndex
|
|
|
|
|
) -> ViewportPresentationAnchor? {
|
|
|
|
|
guard let scrollView = textView.enclosingScrollView,
|
|
|
|
|
textView.string.utf16.count > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let location = presentationAnchorLocation(
|
|
|
|
|
for: selectedRange,
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
textLength: textView.string.utf16.count
|
|
|
|
|
)
|
|
|
|
|
guard let point = textView.presentationAnchorPoint(at: location) else { return nil }
|
|
|
|
|
|
|
|
|
|
let visibleOrigin = scrollView.contentView.bounds.origin
|
|
|
|
|
return ViewportPresentationAnchor(
|
|
|
|
|
characterLocation: location,
|
|
|
|
|
visibleOrigin: visibleOrigin,
|
|
|
|
|
viewportOffsetY: point.y - visibleOrigin.y
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func presentationAnchorLocation(
|
|
|
|
|
for selectedRange: NSRange,
|
|
|
|
|
lineIndex: DocumentLineIndex,
|
|
|
|
|
textLength: Int
|
|
|
|
|
) -> Int {
|
|
|
|
|
let selectedLocation = min(max(0, selectedRange.location), max(0, textLength - 1))
|
|
|
|
|
let lineNumber = lineIndex.lineIndex(containing: selectedLocation)
|
|
|
|
|
guard let line = lineIndex.editorLine(at: lineNumber, activeLineIndex: lineNumber) else {
|
|
|
|
|
return selectedLocation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let renderPlan = HybridMarkdownLineRenderer().renderPlan(for: line)
|
|
|
|
|
switch renderPlan.kind {
|
|
|
|
|
case .heading(_, _, let textRange):
|
|
|
|
|
return selectedLocation < textRange.location ? textRange.location : selectedLocation
|
|
|
|
|
case .taskList(_, _, let contentRange, _, _):
|
|
|
|
|
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
|
|
|
|
|
case .unorderedList(_, let contentRange, _),
|
|
|
|
|
.orderedList(_, let contentRange, _),
|
|
|
|
|
.blockquote(_, let contentRange):
|
|
|
|
|
return selectedLocation < contentRange.location ? contentRange.location : selectedLocation
|
|
|
|
|
default:
|
|
|
|
|
return selectedLocation
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
private final class EditorTextView: NSTextView {
|
2026-06-01 09:27:22 +02:00
|
|
|
var onFocusStateChange: ((NSTextView) -> Void)?
|
2026-06-01 09:40:19 +02:00
|
|
|
var onUserEditingInteraction: ((NSTextView) -> Void)?
|
2026-06-01 14:22:27 +02:00
|
|
|
var codeBlockContainers: [CodeBlockContainerPresentation] = [] {
|
|
|
|
|
didSet {
|
|
|
|
|
guard oldValue != codeBlockContainers else { return }
|
|
|
|
|
needsDisplay = true
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 09:27:22 +02:00
|
|
|
|
2026-05-29 19:47:40 +02:00
|
|
|
override var acceptsFirstResponder: Bool {
|
|
|
|
|
true
|
|
|
|
|
}
|
2026-06-01 09:27:22 +02:00
|
|
|
|
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
|
|
|
let becameFirstResponder = super.becomeFirstResponder()
|
|
|
|
|
if becameFirstResponder {
|
|
|
|
|
onFocusStateChange?(self)
|
|
|
|
|
}
|
|
|
|
|
return becameFirstResponder
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
|
|
|
let resignedFirstResponder = super.resignFirstResponder()
|
|
|
|
|
if resignedFirstResponder {
|
|
|
|
|
onFocusStateChange?(self)
|
|
|
|
|
}
|
|
|
|
|
return resignedFirstResponder
|
|
|
|
|
}
|
2026-06-01 09:40:19 +02:00
|
|
|
|
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
|
|
|
onUserEditingInteraction?(self)
|
|
|
|
|
super.mouseDown(with: event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
|
|
|
onUserEditingInteraction?(self)
|
|
|
|
|
super.keyDown(with: event)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func paste(_ sender: Any?) {
|
|
|
|
|
onUserEditingInteraction?(self)
|
|
|
|
|
super.paste(sender)
|
|
|
|
|
}
|
2026-06-01 14:22:27 +02:00
|
|
|
|
|
|
|
|
func invalidateCodeBlockContainers() {
|
|
|
|
|
needsDisplay = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func codeBlockContainerFrame(containing lineIndex: Int) -> NSRect? {
|
|
|
|
|
guard let container = codeBlockContainers.first(where: { $0.codeBlock.lineIndexes.contains(lineIndex) }) else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return codeBlockFrame(for: container)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func drawBackground(in rect: NSRect) {
|
|
|
|
|
super.drawBackground(in: rect)
|
|
|
|
|
drawCodeBlockContainers(in: rect)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func drawCodeBlockContainers(in dirtyRect: NSRect) {
|
|
|
|
|
for container in codeBlockContainers {
|
|
|
|
|
guard let frame = codeBlockFrame(for: container),
|
|
|
|
|
frame.intersects(dirtyRect)
|
|
|
|
|
else { continue }
|
|
|
|
|
|
|
|
|
|
drawCodeBlockContainer(container, in: frame)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func drawCodeBlockContainer(
|
|
|
|
|
_ container: CodeBlockContainerPresentation,
|
|
|
|
|
in frame: NSRect
|
|
|
|
|
) {
|
|
|
|
|
let cornerRadius: CGFloat = 8
|
|
|
|
|
let headerHeight = min(30, max(24, frame.height * 0.32))
|
|
|
|
|
let headerRect = NSRect(x: frame.minX, y: frame.minY, width: frame.width, height: headerHeight)
|
|
|
|
|
|
|
|
|
|
let bodyPath = NSBezierPath(roundedRect: frame, xRadius: cornerRadius, yRadius: cornerRadius)
|
|
|
|
|
NSColor.controlAccentColor.withAlphaComponent(0.075).setFill()
|
|
|
|
|
bodyPath.fill()
|
|
|
|
|
|
|
|
|
|
NSGraphicsContext.saveGraphicsState()
|
|
|
|
|
bodyPath.addClip()
|
|
|
|
|
NSColor.controlAccentColor.withAlphaComponent(0.12).setFill()
|
|
|
|
|
headerRect.fill()
|
|
|
|
|
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
|
|
|
|
|
NSBezierPath.strokeLine(
|
|
|
|
|
from: NSPoint(x: headerRect.minX, y: headerRect.maxY),
|
|
|
|
|
to: NSPoint(x: headerRect.maxX, y: headerRect.maxY)
|
|
|
|
|
)
|
|
|
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
|
|
|
|
|
|
|
|
NSColor.separatorColor.withAlphaComponent(0.35).setStroke()
|
|
|
|
|
bodyPath.lineWidth = 1
|
|
|
|
|
bodyPath.stroke()
|
|
|
|
|
|
|
|
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
|
|
|
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold),
|
|
|
|
|
.foregroundColor: NSColor.secondaryLabelColor
|
|
|
|
|
]
|
|
|
|
|
let label = NSAttributedString(string: container.languageLabel, attributes: labelAttributes)
|
|
|
|
|
let labelRect = NSRect(
|
|
|
|
|
x: headerRect.minX + 14,
|
|
|
|
|
y: headerRect.midY - ceil(label.size().height / 2),
|
|
|
|
|
width: max(0, headerRect.width - 28),
|
|
|
|
|
height: label.size().height
|
|
|
|
|
)
|
|
|
|
|
label.draw(in: labelRect)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func codeBlockFrame(for container: CodeBlockContainerPresentation) -> NSRect? {
|
|
|
|
|
guard let layoutManager,
|
|
|
|
|
let textContainer,
|
|
|
|
|
textStorage?.length ?? 0 > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let textLength = string.utf16.count
|
|
|
|
|
let sourceRange = NSRange(
|
|
|
|
|
location: min(container.codeBlock.sourceRange.location, max(0, textLength - 1)),
|
|
|
|
|
length: min(
|
|
|
|
|
container.codeBlock.sourceRange.length,
|
|
|
|
|
max(0, textLength - container.codeBlock.sourceRange.location)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
guard sourceRange.location < textLength else { return nil }
|
|
|
|
|
|
|
|
|
|
layoutManager.ensureLayout(forCharacterRange: sourceRange)
|
|
|
|
|
let firstCharacterRange = NSRange(location: sourceRange.location, length: 1)
|
|
|
|
|
let lastLocation = max(sourceRange.location, min(sourceRange.upperBound - 1, textLength - 1))
|
|
|
|
|
let lastCharacterRange = NSRange(location: lastLocation, length: 1)
|
|
|
|
|
let firstGlyphRange = layoutManager.glyphRange(
|
|
|
|
|
forCharacterRange: firstCharacterRange,
|
|
|
|
|
actualCharacterRange: nil
|
|
|
|
|
)
|
|
|
|
|
let lastGlyphRange = layoutManager.glyphRange(
|
|
|
|
|
forCharacterRange: lastCharacterRange,
|
|
|
|
|
actualCharacterRange: nil
|
|
|
|
|
)
|
|
|
|
|
guard firstGlyphRange.length > 0, lastGlyphRange.length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
let firstFragment = layoutManager.lineFragmentRect(forGlyphAt: firstGlyphRange.location, effectiveRange: nil)
|
|
|
|
|
let lastFragment = layoutManager.lineFragmentRect(forGlyphAt: lastGlyphRange.location, effectiveRange: nil)
|
|
|
|
|
let origin = textContainerOrigin
|
|
|
|
|
let horizontalInset: CGFloat = 4
|
|
|
|
|
return NSRect(
|
|
|
|
|
x: origin.x + horizontalInset,
|
|
|
|
|
y: origin.y + firstFragment.minY,
|
|
|
|
|
width: max(0, textContainer.containerSize.width - horizontalInset * 2),
|
|
|
|
|
height: max(0, lastFragment.maxY - firstFragment.minY)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-05-29 19:47:40 +02:00
|
|
|
}
|
|
|
|
|
|
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?
|
2026-06-01 09:56:36 +02:00
|
|
|
var onEditorLayoutInvalidated: ((NSTextView) -> Void)?
|
|
|
|
|
private var lastLayoutSize: NSSize = .zero
|
2026-05-29 19:19:59 +02:00
|
|
|
|
|
|
|
|
override func layout() {
|
|
|
|
|
super.layout()
|
2026-06-01 09:56:36 +02:00
|
|
|
let didChangeInsets = updateEditorInsets()
|
2026-05-29 19:19:59 +02:00
|
|
|
|
|
|
|
|
guard let editorTextView else { return }
|
2026-06-01 09:56:36 +02:00
|
|
|
let layoutSize = contentView.bounds.size
|
|
|
|
|
if didChangeInsets || layoutSize != lastLayoutSize {
|
|
|
|
|
lastLayoutSize = layoutSize
|
|
|
|
|
onEditorLayoutInvalidated?(editorTextView)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@discardableResult
|
|
|
|
|
func updateEditorInsets() -> Bool {
|
|
|
|
|
guard let editorTextView else { return false }
|
2026-05-29 19:19:59 +02:00
|
|
|
|
|
|
|
|
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-06-01 09:56:36 +02:00
|
|
|
return true
|
2026-05-29 19:19:59 +02:00
|
|
|
}
|
2026-06-01 09:56:36 +02:00
|
|
|
return false
|
2026-05-29 19:19:59 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-01 09:40:19 +02:00
|
|
|
|
|
|
|
|
#if DEBUG
|
|
|
|
|
@MainActor
|
|
|
|
|
public final class HybridMarkdownLiveEditorHarness {
|
|
|
|
|
public private(set) var text: String
|
|
|
|
|
public private(set) var selection: EditorSelection
|
|
|
|
|
public private(set) var renderPasses: [EditorRenderPassMetric] = []
|
|
|
|
|
|
|
|
|
|
private let box: StateBox
|
|
|
|
|
private let coordinator: NativeMarkdownTextView.Coordinator
|
|
|
|
|
private let window: NSWindow
|
|
|
|
|
private let scrollView: ComfortableEditorScrollView
|
|
|
|
|
private let textView: EditorTextView
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
public init(
|
|
|
|
|
source: String,
|
|
|
|
|
selectedRange: NSRange = NSRange(location: 0, length: 0),
|
|
|
|
|
initialWidth: CGFloat = 640
|
|
|
|
|
) {
|
2026-06-01 09:40:19 +02:00
|
|
|
let stateBox = StateBox(text: source, selection: EditorSelection(range: selectedRange))
|
|
|
|
|
self.text = source
|
|
|
|
|
self.selection = EditorSelection(range: selectedRange)
|
|
|
|
|
self.box = stateBox
|
|
|
|
|
|
|
|
|
|
let lineIndex = DocumentLineIndex(source: source)
|
|
|
|
|
let parent = NativeMarkdownTextView(
|
|
|
|
|
text: Binding(
|
|
|
|
|
get: { stateBox.text },
|
|
|
|
|
set: { stateBox.text = $0 }
|
|
|
|
|
),
|
|
|
|
|
selection: Binding(
|
|
|
|
|
get: { stateBox.selection },
|
|
|
|
|
set: { stateBox.selection = $0 }
|
|
|
|
|
),
|
|
|
|
|
activeLineIndex: lineIndex.lineIndex(containing: selectedRange.location),
|
|
|
|
|
lineIndex: lineIndex,
|
|
|
|
|
onTextEdit: { updatedText, edit, updatedSelection in
|
|
|
|
|
stateBox.text = updatedText
|
|
|
|
|
if let edit {
|
|
|
|
|
stateBox.lineIndex.replace(edit, updatedSource: updatedText)
|
|
|
|
|
} else {
|
|
|
|
|
stateBox.lineIndex = DocumentLineIndex(source: updatedText)
|
|
|
|
|
}
|
|
|
|
|
if let updatedSelection {
|
|
|
|
|
stateBox.selection = updatedSelection
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onRenderPass: { metric in
|
|
|
|
|
stateBox.renderPasses.append(metric)
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.coordinator = parent.makeCoordinator()
|
|
|
|
|
self.coordinator.currentLineIndex = lineIndex
|
|
|
|
|
self.scrollView = ComfortableEditorScrollView()
|
|
|
|
|
self.scrollView.hasVerticalScroller = true
|
|
|
|
|
self.scrollView.hasHorizontalScroller = false
|
|
|
|
|
self.scrollView.autohidesScrollers = true
|
|
|
|
|
self.scrollView.borderType = .noBorder
|
|
|
|
|
self.scrollView.drawsBackground = true
|
|
|
|
|
self.scrollView.backgroundColor = .textBackgroundColor
|
|
|
|
|
|
|
|
|
|
let textStorage = NSTextStorage()
|
|
|
|
|
let layoutManager = NSLayoutManager()
|
|
|
|
|
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
|
|
|
|
textContainer.widthTracksTextView = true
|
|
|
|
|
textContainer.heightTracksTextView = false
|
|
|
|
|
layoutManager.addTextContainer(textContainer)
|
|
|
|
|
textStorage.addLayoutManager(layoutManager)
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
self.textView = EditorTextView(
|
|
|
|
|
frame: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
|
|
|
|
|
textContainer: textContainer
|
|
|
|
|
)
|
2026-06-01 09:40:19 +02:00
|
|
|
self.textView.autoresizingMask = [.width]
|
|
|
|
|
self.textView.minSize = NSSize(width: 0, height: 480)
|
|
|
|
|
self.textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
self.textView.isVerticallyResizable = true
|
|
|
|
|
self.textView.isHorizontallyResizable = false
|
|
|
|
|
self.textView.onFocusStateChange = { [weak coordinator] textView in
|
|
|
|
|
coordinator?.applyHybridAttributes(to: textView)
|
|
|
|
|
}
|
|
|
|
|
self.textView.onUserEditingInteraction = { [weak coordinator] textView in
|
|
|
|
|
coordinator?.activateEditingPresentation(in: textView)
|
|
|
|
|
}
|
|
|
|
|
self.textView.string = source
|
|
|
|
|
self.textView.delegate = coordinator
|
|
|
|
|
self.textView.setSelectedRange(selectedRange)
|
|
|
|
|
self.textView.isRichText = false
|
|
|
|
|
self.textView.isEditable = true
|
|
|
|
|
self.textView.isSelectable = true
|
|
|
|
|
self.textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
|
|
|
self.textView.isAutomaticDashSubstitutionEnabled = false
|
|
|
|
|
self.textView.isAutomaticTextReplacementEnabled = false
|
|
|
|
|
self.textView.allowsUndo = true
|
|
|
|
|
self.textView.usesFindPanel = true
|
|
|
|
|
self.textView.isContinuousSpellCheckingEnabled = true
|
|
|
|
|
self.textView.backgroundColor = .textBackgroundColor
|
|
|
|
|
self.textView.insertionPointColor = .controlAccentColor
|
|
|
|
|
self.textView.font = .systemFont(ofSize: 16, weight: .regular)
|
|
|
|
|
self.textView.textContainer?.widthTracksTextView = true
|
2026-06-01 09:56:36 +02:00
|
|
|
self.textView.textContainer?.containerSize = NSSize(width: initialWidth, height: CGFloat.greatestFiniteMagnitude)
|
2026-06-01 09:40:19 +02:00
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
self.scrollView.frame = NSRect(x: 0, y: 0, width: initialWidth, height: 480)
|
2026-06-01 09:40:19 +02:00
|
|
|
self.scrollView.documentView = textView
|
|
|
|
|
self.scrollView.editorTextView = textView
|
2026-06-01 09:56:36 +02:00
|
|
|
self.scrollView.onEditorLayoutInvalidated = { [weak coordinator] textView in
|
|
|
|
|
coordinator?.syncChecklistControlFrames(in: textView)
|
2026-06-01 14:22:27 +02:00
|
|
|
(textView as? EditorTextView)?.invalidateCodeBlockContainers()
|
2026-06-01 09:56:36 +02:00
|
|
|
}
|
2026-06-01 09:40:19 +02:00
|
|
|
self.scrollView.updateEditorInsets()
|
|
|
|
|
|
|
|
|
|
self.window = NSWindow(
|
2026-06-01 09:56:36 +02:00
|
|
|
contentRect: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
|
2026-06-01 09:40:19 +02:00
|
|
|
styleMask: [.titled],
|
|
|
|
|
backing: .buffered,
|
|
|
|
|
defer: false
|
|
|
|
|
)
|
|
|
|
|
self.window.contentView = scrollView
|
|
|
|
|
self.coordinator.applyHybridAttributes(to: textView)
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func simulateLaunchFirstResponder() {
|
|
|
|
|
window.makeFirstResponder(textView)
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func simulateFocusAway() {
|
|
|
|
|
window.makeFirstResponder(nil)
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func setSelection(_ range: NSRange) {
|
|
|
|
|
coordinator.activateEditingPresentation(in: textView)
|
|
|
|
|
coordinator.setSelection(range, in: textView)
|
|
|
|
|
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func clickRenderedCheckbox(lineIndex: Int) {
|
|
|
|
|
guard let button = checklistButton(lineIndex: lineIndex) else { return }
|
|
|
|
|
button.performClick(nil)
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
public func simulateLayout(width: CGFloat) {
|
|
|
|
|
window.setContentSize(NSSize(width: width, height: 480))
|
|
|
|
|
scrollView.frame = NSRect(x: 0, y: 0, width: width, height: 480)
|
|
|
|
|
textView.frame = NSRect(x: 0, y: 0, width: width, height: textView.frame.height)
|
|
|
|
|
textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
scrollView.layoutSubtreeIfNeeded()
|
|
|
|
|
scrollView.layout()
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
public func scrollViewport(toY y: CGFloat) {
|
|
|
|
|
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
|
|
|
|
|
let target = NSPoint(x: 0, y: max(0, min(y, maxY)))
|
|
|
|
|
scrollView.contentView.scroll(to: target)
|
|
|
|
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
|
|
|
|
syncState()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func viewportOrigin() -> CGPoint {
|
|
|
|
|
scrollView.contentView.bounds.origin
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
public func headingMarkerIsHidden() -> Bool {
|
|
|
|
|
isHidden(at: 0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 10:17:17 +02:00
|
|
|
public func characterIsHidden(at location: Int) -> Bool {
|
|
|
|
|
isHidden(at: location)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
public func point(for text: String) -> CGPoint? {
|
|
|
|
|
let textRange = (textView.string as NSString).range(of: text)
|
|
|
|
|
guard textRange.location != NSNotFound,
|
|
|
|
|
let layoutManager = textView.layoutManager,
|
|
|
|
|
let textContainer = textView.textContainer
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
|
|
|
let glyphRange = layoutManager.glyphRange(
|
|
|
|
|
forCharacterRange: NSRange(location: textRange.location, length: 1),
|
|
|
|
|
actualCharacterRange: nil
|
|
|
|
|
)
|
|
|
|
|
guard glyphRange.length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
|
|
|
|
|
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
|
|
|
|
|
let origin = textView.textContainerOrigin
|
|
|
|
|
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
public func viewportPoint(for text: String) -> CGPoint? {
|
|
|
|
|
guard let point = point(for: text) else { return nil }
|
|
|
|
|
let origin = viewportOrigin()
|
|
|
|
|
return CGPoint(x: point.x - origin.x, y: point.y - origin.y)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
public func presentationSignature() -> String {
|
|
|
|
|
guard let storage = textView.textStorage else { return "" }
|
|
|
|
|
return MarkdownPresentationSnapshot.make(
|
|
|
|
|
from: storage,
|
|
|
|
|
lineIndex: coordinator.currentLineIndex,
|
|
|
|
|
containerWidth: textView.textContainer?.containerSize.width ?? 640
|
|
|
|
|
).signature
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func checklistButtonFrame(lineIndex: Int) -> CGRect? {
|
|
|
|
|
checklistButton(lineIndex: lineIndex)?.frame
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
public func checklistAlignmentDelta(lineIndex: Int) -> CGFloat? {
|
|
|
|
|
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
|
|
|
|
|
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
return abs(buttonFrame.midY - labelFrame.midY)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func checklistLabelGap(lineIndex: Int) -> CGFloat? {
|
|
|
|
|
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
|
|
|
|
|
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
return labelFrame.minX - buttonFrame.maxX
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:22:27 +02:00
|
|
|
public func codeBlockContainerCount() -> Int {
|
|
|
|
|
textView.codeBlockContainers.count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func codeBlockContainerLabel(containing lineIndex: Int) -> String? {
|
|
|
|
|
textView.codeBlockContainers.first { $0.codeBlock.lineIndexes.contains(lineIndex) }?.languageLabel
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func codeBlockContainerFrame(containing lineIndex: Int) -> CGRect? {
|
|
|
|
|
textView.codeBlockContainerFrame(containing: lineIndex)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
public func selectedRange() -> NSRange {
|
|
|
|
|
textView.selectedRange()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func source() -> String {
|
|
|
|
|
textView.string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func effectiveActiveLineIndex() -> Int {
|
|
|
|
|
coordinator.currentLineIndex.lineIndex(containing: textView.selectedRange().location)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func checklistButton(lineIndex: Int) -> ChecklistOverlayButton? {
|
|
|
|
|
textView.subviews.compactMap { $0 as? ChecklistOverlayButton }.first { $0.task?.lineIndex == lineIndex }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:56:36 +02:00
|
|
|
private func checklistLabelFrame(lineIndex: Int) -> CGRect? {
|
|
|
|
|
guard let task = checklistButton(lineIndex: lineIndex)?.task,
|
|
|
|
|
task.contentRange.location < textView.string.utf16.count,
|
|
|
|
|
let layoutManager = textView.layoutManager,
|
|
|
|
|
let textContainer = textView.textContainer
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
|
|
|
let glyphRange = layoutManager.glyphRange(
|
|
|
|
|
forCharacterRange: NSRange(location: task.contentRange.location, length: 1),
|
|
|
|
|
actualCharacterRange: nil
|
|
|
|
|
)
|
|
|
|
|
guard glyphRange.length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
|
|
|
|
|
let origin = textView.textContainerOrigin
|
|
|
|
|
return CGRect(
|
|
|
|
|
x: origin.x + fragment.minX,
|
|
|
|
|
y: origin.y + fragment.minY,
|
|
|
|
|
width: fragment.width,
|
|
|
|
|
height: fragment.height
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:40:19 +02:00
|
|
|
private func isHidden(at location: Int) -> Bool {
|
|
|
|
|
guard let textStorage = textView.textStorage else { return false }
|
|
|
|
|
let color = textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
|
|
|
|
|
let font = textStorage.attribute(.font, at: location, effectiveRange: nil) as? NSFont
|
|
|
|
|
return color?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func syncState() {
|
|
|
|
|
text = box.text
|
|
|
|
|
selection = box.selection
|
|
|
|
|
renderPasses = box.renderPasses
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private final class StateBox {
|
|
|
|
|
var text: String
|
|
|
|
|
var selection: EditorSelection
|
|
|
|
|
var lineIndex: DocumentLineIndex
|
|
|
|
|
var renderPasses: [EditorRenderPassMetric] = []
|
|
|
|
|
|
|
|
|
|
init(text: String, selection: EditorSelection) {
|
|
|
|
|
self.text = text
|
|
|
|
|
self.selection = selection
|
|
|
|
|
self.lineIndex = DocumentLineIndex(source: text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
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.text = text
|
2026-06-01 09:40:19 +02:00
|
|
|
textView.delegate = context.coordinator
|
2026-05-29 17:55:37 +02:00
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
private var lastStyledEditableRegion: EditableRegion?
|
2026-05-30 19:24:48 +02:00
|
|
|
private var pendingEdit: DocumentLineIndexEdit?
|
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-06-01 10:17:17 +02:00
|
|
|
let editableRegion = textView.isFirstResponder
|
|
|
|
|
? EditableRegion.selection(textView.selectedRange, in: currentLineIndex)
|
|
|
|
|
: .none()
|
|
|
|
|
let activeLineIndex = editableRegion.primaryLineIndex
|
|
|
|
|
let invalidationPlan = invalidationPlan(for: textView.text, editableRegion: editableRegion)
|
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,
|
2026-06-01 10:17:17 +02:00
|
|
|
accentColor: .systemBlue,
|
|
|
|
|
editableRegion: editableRegion
|
2026-05-29 19:02:51 +02:00
|
|
|
)
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
lastStyledEditableRegion = editableRegion
|
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
|
|
|
}
|
|
|
|
|
|
2026-06-01 09:27:22 +02:00
|
|
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
|
|
|
|
applyHybridAttributes(to: textView)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidEndEditing(_ textView: UITextView) {
|
|
|
|
|
applyHybridAttributes(to: 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-06-01 10:17:17 +02:00
|
|
|
lastStyledEditableRegion = 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-06-01 10:17:17 +02:00
|
|
|
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
|
|
|
|
|
let previousEditableRegion = lastStyledEditableRegion
|
|
|
|
|
let plan = EditorDirtyLineInvalidator.plan(
|
2026-05-29 20:57:03 +02:00
|
|
|
previousText: lastStyledText,
|
2026-05-30 19:24:48 +02:00
|
|
|
currentLineIndex: currentLineIndex,
|
|
|
|
|
edit: pendingEdit,
|
2026-06-01 10:17:17 +02:00
|
|
|
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
|
|
|
|
|
currentActiveLineIndex: editableRegion.primaryLineIndex
|
|
|
|
|
)
|
|
|
|
|
return plan.includingEditableRegionTransition(
|
|
|
|
|
from: previousEditableRegion,
|
|
|
|
|
to: editableRegion,
|
|
|
|
|
lineCount: currentLineIndex.lineCount
|
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]
|
2026-06-01 14:22:27 +02:00
|
|
|
var renderedCodeBlocks: [RenderedCodeBlockElement]
|
2026-06-01 10:17:17 +02:00
|
|
|
var editableRegion: EditableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
|
|
|
|
|
static let empty = MarkdownTextStylingResult(
|
|
|
|
|
totalLineCount: 0,
|
|
|
|
|
styledLineCount: 0,
|
|
|
|
|
styledLineIndexes: [],
|
2026-06-01 10:17:17 +02:00
|
|
|
renderedTasks: [],
|
2026-06-01 14:22:27 +02:00
|
|
|
renderedCodeBlocks: [],
|
2026-06-01 10:17:17 +02:00
|
|
|
editableRegion: .none()
|
2026-05-31 23:01:11 +02:00
|
|
|
)
|
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,
|
2026-06-01 10:17:17 +02:00
|
|
|
usesRenderedControls: Bool = false,
|
|
|
|
|
editableRegion: EditableRegion? = nil
|
2026-05-29 20:57:03 +02:00
|
|
|
) -> MarkdownTextStylingResult {
|
2026-06-01 10:17:17 +02:00
|
|
|
let resolvedEditableRegion = editableRegion
|
|
|
|
|
?? (activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none())
|
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),
|
2026-06-01 10:17:17 +02:00
|
|
|
renderedTasks: [],
|
2026-06-01 14:22:27 +02:00
|
|
|
renderedCodeBlocks: [],
|
2026-06-01 10:17:17 +02:00
|
|
|
editableRegion: resolvedEditableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
)
|
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,
|
2026-06-01 10:17:17 +02:00
|
|
|
editableRegion: resolvedEditableRegion,
|
2026-05-31 23:01:11 +02:00
|
|
|
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-06-01 09:27:22 +02:00
|
|
|
let paragraphRange = presentationRange(for: line, in: lineIndex, textLength: textStorage.length)
|
|
|
|
|
resetAttributes(in: textStorage, range: paragraphRange, textColor: textColor)
|
2026-05-29 20:57:03 +02:00
|
|
|
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-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-29 17:55:37 +02:00
|
|
|
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-06-01 09:27:22 +02:00
|
|
|
paragraphRange: paragraphRange,
|
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,
|
2026-06-01 10:17:17 +02:00
|
|
|
renderedTasks: presentationState.renderedTasks,
|
2026-06-01 14:22:27 +02:00
|
|
|
renderedCodeBlocks: presentationState.renderedCodeBlocks,
|
2026-06-01 10:17:17 +02:00
|
|
|
editableRegion: presentationState.editableRegion
|
2026-05-31 23:01:11 +02:00
|
|
|
)
|
2026-05-31 22:21:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 20:57:03 +02:00
|
|
|
private static func resetAttributes(
|
|
|
|
|
in textStorage: NSTextStorage,
|
2026-06-01 09:27:22 +02:00
|
|
|
range: NSRange,
|
2026-05-29 20:57:03 +02:00
|
|
|
textColor: PlatformColor
|
|
|
|
|
) {
|
2026-06-01 09:27:22 +02:00
|
|
|
guard range.length > 0 else { return }
|
|
|
|
|
textStorage.setAttributes(baseAttributes(textColor: textColor), range: range)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func presentationRange(
|
|
|
|
|
for line: EditorLine,
|
|
|
|
|
in lineIndex: DocumentLineIndex,
|
|
|
|
|
textLength: Int
|
|
|
|
|
) -> NSRange {
|
|
|
|
|
guard let boundary = lineIndex.boundary(at: line.index) else {
|
|
|
|
|
return line.range
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let upperBound = min(boundary.nextLineLocation, textLength)
|
|
|
|
|
return NSRange(
|
|
|
|
|
location: boundary.contentRange.location,
|
|
|
|
|
length: max(0, upperBound - boundary.contentRange.location)
|
|
|
|
|
)
|
2026-05-29 17:55:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func styleRenderedLine(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
line: EditorLine,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: NSRange,
|
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
|
|
|
.paragraphStyle: headingParagraphStyle(level: level)
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
|
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()
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
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,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: paragraphRange,
|
2026-05-31 20:47:34 +02:00
|
|
|
markerRange: markerRange,
|
|
|
|
|
contentRange: contentRange,
|
|
|
|
|
nestingLevel: nestingLevel,
|
|
|
|
|
secondaryTextColor: secondaryTextColor
|
|
|
|
|
)
|
|
|
|
|
case .orderedList(let markerRange, let contentRange, let nestingLevel):
|
|
|
|
|
styleListLine(
|
|
|
|
|
in: textStorage,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: paragraphRange,
|
2026-05-31 20:47:34 +02:00
|
|
|
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,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: paragraphRange,
|
2026-05-31 20:47:34 +02:00
|
|
|
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)
|
|
|
|
|
}
|
2026-06-01 14:22:27 +02:00
|
|
|
case .fencedCodeFence(_, _, let role):
|
|
|
|
|
textStorage.addAttributes(codeBlockFenceAttributes(role: role), range: paragraphRange)
|
|
|
|
|
hideSyntax(in: textStorage, range: line.range)
|
2026-06-01 10:17:17 +02:00
|
|
|
case .codeBlockContent(let language):
|
2026-06-01 14:22:27 +02:00
|
|
|
textStorage.addAttributes(codeBlockContentAttributes(), range: paragraphRange)
|
2026-06-01 10:17:17 +02:00
|
|
|
styleCodeSyntax(
|
|
|
|
|
in: textStorage,
|
|
|
|
|
line: line,
|
|
|
|
|
language: language,
|
|
|
|
|
textColor: textColor,
|
|
|
|
|
secondaryTextColor: secondaryTextColor,
|
|
|
|
|
accentColor: accentColor
|
|
|
|
|
)
|
2026-05-31 20:47:34 +02:00
|
|
|
case .tableRow(_, let separatorRanges, let isDivider):
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .regular),
|
|
|
|
|
.backgroundColor: accentColor.withAlphaComponent(0.06),
|
|
|
|
|
.paragraphStyle: tableParagraphStyle()
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
for separatorRange in separatorRanges {
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
|
|
|
|
|
}
|
|
|
|
|
if isDivider {
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: secondaryTextColor,
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .semibold)
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
}
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
private static func styleCodeSyntax(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
line: EditorLine,
|
|
|
|
|
language: String?,
|
|
|
|
|
textColor: PlatformColor,
|
|
|
|
|
secondaryTextColor: PlatformColor,
|
|
|
|
|
accentColor: PlatformColor
|
|
|
|
|
) {
|
|
|
|
|
let normalizedLanguage = normalizedCodeLanguage(language)
|
|
|
|
|
guard normalizedLanguage != "text" else { return }
|
|
|
|
|
|
|
|
|
|
switch normalizedLanguage {
|
|
|
|
|
case "swift":
|
|
|
|
|
applyCodeRegex("\\b(import|struct|class|enum|protocol|extension|func|let|var|if|else|guard|return|for|while|switch|case|default|in|try|throw|throws|async|await|public|private|internal|static|final|init|self|nil|true|false)\\b", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: codeKeywordColor(accentColor: accentColor),
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .semibold)
|
|
|
|
|
], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("//.*$", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
case "json":
|
|
|
|
|
applyCodeRegex("\"(?:\\\\.|[^\"\\\\])*\"\\s*:", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: codeKeywordColor(accentColor: accentColor),
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .semibold)
|
|
|
|
|
], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex(":\\s*(\"(?:\\\\.|[^\"\\\\])*\")", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeStringColor(accentColor: accentColor)], range: match.range(at: 1))
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("\\b(true|false|null)\\b", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeKeywordColor(accentColor: accentColor)], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("-?\\b\\d+(?:\\.\\d+)?\\b", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
case "yaml":
|
|
|
|
|
applyCodeRegex("^\\s*[A-Za-z0-9_-]+\\s*:", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: codeKeywordColor(accentColor: accentColor),
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .semibold)
|
|
|
|
|
], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("#.*$", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("\\b(true|false|null|yes|no)\\b", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: codeNumberColor(accentColor: accentColor)], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
case "markdown":
|
|
|
|
|
applyCodeRegex("^\\s{0,3}#{1,6}\\s+.*$", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: codeKeywordColor(accentColor: accentColor),
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .semibold)
|
|
|
|
|
], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
applyCodeRegex("(\\*\\*[^*]+\\*\\*|`[^`]+`|\\[[^\\]]+\\]\\([^)]+\\))", in: textStorage, line: line) { match in
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: accentColor], range: match.range)
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
textStorage.addAttributes([.foregroundColor: textColor], range: line.range)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func normalizedCodeLanguage(_ language: String?) -> String {
|
|
|
|
|
let normalized = language?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
|
switch normalized {
|
|
|
|
|
case "swift", "json", "yaml", "yml":
|
|
|
|
|
return normalized == "yml" ? "yaml" : normalized ?? "text"
|
|
|
|
|
case "md", "markdown":
|
|
|
|
|
return "markdown"
|
|
|
|
|
default:
|
|
|
|
|
return "text"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 10:17:17 +02:00
|
|
|
private static func applyCodeRegex(
|
|
|
|
|
_ pattern: String,
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
line: EditorLine,
|
|
|
|
|
handler: (NSTextCheckingResult) -> Void
|
|
|
|
|
) {
|
|
|
|
|
guard line.range.length > 0,
|
|
|
|
|
let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines])
|
|
|
|
|
else { return }
|
|
|
|
|
|
|
|
|
|
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 17:55:37 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:22:27 +02:00
|
|
|
private static func codeBlockContentParagraphStyle() -> NSMutableParagraphStyle {
|
2026-05-31 20:47:34 +02:00
|
|
|
let paragraph = NSMutableParagraphStyle()
|
|
|
|
|
paragraph.lineSpacing = 3
|
2026-06-01 14:22:27 +02:00
|
|
|
paragraph.paragraphSpacing = 0
|
|
|
|
|
paragraph.paragraphSpacingBefore = 0
|
|
|
|
|
paragraph.firstLineHeadIndent = 18
|
|
|
|
|
paragraph.headIndent = 18
|
|
|
|
|
return paragraph
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeBlockFenceParagraphStyle(role: FencedCodeFenceRole) -> NSMutableParagraphStyle {
|
|
|
|
|
let paragraph = NSMutableParagraphStyle()
|
|
|
|
|
paragraph.lineSpacing = 0
|
|
|
|
|
paragraph.paragraphSpacing = role == .closing ? 10 : 0
|
|
|
|
|
paragraph.paragraphSpacingBefore = role == .opening ? 8 : 0
|
|
|
|
|
paragraph.minimumLineHeight = role == .opening ? 30 : 12
|
|
|
|
|
paragraph.maximumLineHeight = role == .opening ? 30 : 12
|
|
|
|
|
paragraph.firstLineHeadIndent = 18
|
|
|
|
|
paragraph.headIndent = 18
|
2026-05-31 20:47:34 +02:00
|
|
|
return paragraph
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:22:27 +02:00
|
|
|
private static func codeBlockContentAttributes() -> [NSAttributedString.Key: Any] {
|
|
|
|
|
[
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .regular),
|
|
|
|
|
.paragraphStyle: codeBlockContentParagraphStyle()
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeBlockFenceAttributes(role: FencedCodeFenceRole) -> [NSAttributedString.Key: Any] {
|
2026-05-31 20:47:34 +02:00
|
|
|
[
|
|
|
|
|
.font: monospacedFont(size: 15, weight: .regular),
|
2026-06-01 14:22:27 +02:00
|
|
|
.paragraphStyle: codeBlockFenceParagraphStyle(role: role)
|
2026-05-31 20:47:34 +02:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func styleListLine(
|
|
|
|
|
in textStorage: NSTextStorage,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: NSRange,
|
2026-05-31 20:47:34 +02:00
|
|
|
markerRange: NSRange,
|
|
|
|
|
contentRange: NSRange,
|
|
|
|
|
nestingLevel: Int,
|
|
|
|
|
secondaryTextColor: PlatformColor
|
|
|
|
|
) {
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-31 20:47:34 +02:00
|
|
|
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,
|
2026-06-01 09:27:22 +02:00
|
|
|
paragraphRange: NSRange,
|
2026-05-31 22:21:03 +02:00
|
|
|
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)
|
2026-06-01 09:27:22 +02:00
|
|
|
], range: paragraphRange)
|
2026-05-31 22:21:03 +02:00
|
|
|
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 {
|
2026-06-01 09:27:22 +02:00
|
|
|
hideSyntaxPreservingLayout(in: textStorage, range: checkboxRange, backgroundColor: backgroundColor)
|
2026-05-31 23:01:11 +02:00
|
|
|
}
|
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-06-01 09:27:22 +02:00
|
|
|
private static func hideSyntaxPreservingLayout(
|
|
|
|
|
in textStorage: NSTextStorage,
|
|
|
|
|
range: NSRange,
|
|
|
|
|
backgroundColor: PlatformColor
|
|
|
|
|
) {
|
|
|
|
|
guard range.length > 0 else { return }
|
|
|
|
|
textStorage.addAttributes([
|
|
|
|
|
.foregroundColor: clearColor(),
|
|
|
|
|
.backgroundColor: backgroundColor,
|
|
|
|
|
.font: monospacedFont(size: 10, weight: .regular)
|
|
|
|
|
], range: range)
|
|
|
|
|
}
|
|
|
|
|
|
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-06-01 10:17:17 +02:00
|
|
|
|
|
|
|
|
private static func codeKeywordColor(accentColor: NSColor) -> NSColor {
|
|
|
|
|
.systemPurple
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeStringColor(accentColor: NSColor) -> NSColor {
|
|
|
|
|
.systemRed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeNumberColor(accentColor: NSColor) -> NSColor {
|
|
|
|
|
.systemOrange
|
|
|
|
|
}
|
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-06-01 10:17:17 +02:00
|
|
|
|
|
|
|
|
private static func codeKeywordColor(accentColor: UIColor) -> UIColor {
|
|
|
|
|
.systemPurple
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeStringColor(accentColor: UIColor) -> UIColor {
|
|
|
|
|
.systemRed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func codeNumberColor(accentColor: UIColor) -> UIColor {
|
|
|
|
|
.systemOrange
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
|
2026-06-01 14:44:14 +02:00
|
|
|
#if os(macOS)
|
|
|
|
|
private extension NSTextView {
|
|
|
|
|
func presentationAnchorPoint(at location: Int) -> NSPoint? {
|
|
|
|
|
guard let layoutManager,
|
|
|
|
|
string.utf16.count > 0
|
|
|
|
|
else { return nil }
|
|
|
|
|
|
|
|
|
|
let clampedLocation = min(max(0, location), string.utf16.count - 1)
|
|
|
|
|
let characterRange = NSRange(location: clampedLocation, length: 1)
|
|
|
|
|
layoutManager.ensureLayout(forCharacterRange: characterRange)
|
|
|
|
|
let glyphRange = layoutManager.glyphRange(
|
|
|
|
|
forCharacterRange: characterRange,
|
|
|
|
|
actualCharacterRange: nil
|
|
|
|
|
)
|
|
|
|
|
guard glyphRange.length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
|
|
|
|
|
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
|
|
|
|
|
let origin = textContainerOrigin
|
|
|
|
|
return NSPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-05-29 15:19:33 +02:00
|
|
|
private var platformTextBackground: Color {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
Color(nsColor: .textBackgroundColor)
|
|
|
|
|
#else
|
|
|
|
|
Color(uiColor: .systemBackground)
|
|
|
|
|
#endif
|
|
|
|
|
}
|