Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

1764 lines
68 KiB
Swift

import Foundation
import SwiftUI
import SaplingCore
import SaplingRenderer
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
@Published public private(set) var state: EditorState
public private(set) var instrumentation = EditorInstrumentationSnapshot()
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.state = EditorState(
document: EditorDocument(markdownDocument: document),
activeLineIndex: activeLineIndex
)
}
public var document: MarkdownDocument {
state.document.markdownDocument
}
public var hasUnsavedChanges: Bool {
state.hasUnsavedChanges
}
public var activeLineIndex: Int {
state.activeLineIndex
}
public var lineIndex: DocumentLineIndex {
state.lineIndex
}
public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document)
instrumentation = EditorInstrumentationSnapshot()
}
public func updateSource(_ source: String) {
guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSource(source)
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSource(_ source: String, edit: DocumentLineIndexEdit?, selection: EditorSelection? = nil) {
guard state.document.source != source else { return }
let previousActiveLineIndex = state.activeLineIndex
if let edit {
state.updateSource(source, edit: edit, selection: selection)
} else {
state.updateSource(source)
if let selection {
state.updateSelection(selection)
}
}
instrumentation.recordSourceChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func updateSelection(_ selection: EditorSelection) {
guard state.selection != selection else { return }
let previousActiveLineIndex = state.activeLineIndex
state.updateSelection(selection)
instrumentation.recordSelectionChange()
recordActiveLineChangeIfNeeded(previousActiveLineIndex)
}
public func recordRenderPass(_ metric: EditorRenderPassMetric) {
instrumentation.recordRenderPass(metric)
#if DEBUG
EditorDiagnostics.logRenderPass(metric)
#endif
}
public func save() throws {
try state.document.source.write(to: state.document.url, atomically: true, encoding: .utf8)
state.markSaved()
}
public static func loadDocument(at url: URL) throws -> MarkdownDocument {
let source = try String(contentsOf: url, encoding: .utf8)
let title = url.deletingPathExtension().lastPathComponent
return MarkdownDocument(url: url, title: title, content: source)
}
private func recordActiveLineChangeIfNeeded(_ previousActiveLineIndex: Int) {
if previousActiveLineIndex != state.activeLineIndex {
instrumentation.recordActiveLineChange()
}
}
}
public struct HybridMarkdownEditor: View, EditorView {
@ObservedObject private var viewModel: HybridMarkdownEditorViewModel
private let renderer: any MarkdownRendering
public init(
viewModel: HybridMarkdownEditorViewModel,
renderer: any MarkdownRendering = MarkdownRenderer()
) {
self.viewModel = viewModel
self.renderer = renderer
}
public var state: EditorState {
viewModel.state
}
public var body: some View {
VStack(spacing: 0) {
NativeMarkdownTextView(
text: Binding(
get: { viewModel.state.document.source },
set: { viewModel.updateSource($0) }
),
selection: Binding(
get: { viewModel.state.selection },
set: { viewModel.updateSelection($0) }
),
activeLineIndex: viewModel.state.activeLineIndex,
lineIndex: viewModel.state.lineIndex,
onTextEdit: viewModel.updateSource,
onRenderPass: viewModel.recordRenderPass
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
columnNumber: viewModel.state.activeColumnNumber,
lineCount: viewModel.state.lineCount,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges
)
}
.background(platformTextBackground)
}
}
private struct EditorStatusBar: View {
let activeLineIndex: Int
let columnNumber: Int
let lineCount: Int
let hasUnsavedChanges: Bool
var body: some View {
HStack(spacing: 12) {
Text("Line \(activeLineIndex + 1)")
Text("Column \(columnNumber)")
Text("\(lineCount) lines")
Text(hasUnsavedChanges ? "Modified" : "Saved")
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
Spacer()
}
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.thinMaterial)
}
}
#if os(macOS)
private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = ComfortableEditorScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = true
scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let textView = EditorTextView(frame: scrollView.contentView.bounds, textContainer: textContainer)
textView.autoresizingMask = [.width]
textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.string = text
textView.delegate = context.coordinator
textView.onFocusStateChange = { [weak coordinator = context.coordinator] textView in
coordinator?.applyHybridAttributes(to: textView)
}
textView.onUserEditingInteraction = { [weak coordinator = context.coordinator] textView in
coordinator?.activateEditingPresentation(in: textView)
}
textView.isRichText = false
textView.isEditable = true
textView.isSelectable = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.allowsUndo = true
textView.usesFindPanel = true
textView.isContinuousSpellCheckingEnabled = true
textView.backgroundColor = .textBackgroundColor
textView.insertionPointColor = .controlAccentColor
textView.font = .systemFont(ofSize: 16, weight: .regular)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
scrollView.documentView = textView
scrollView.editorTextView = textView
scrollView.onEditorLayoutInvalidated = { [weak coordinator = context.coordinator] textView in
coordinator?.syncChecklistControlFrames(in: textView)
}
scrollView.updateEditorInsets()
context.coordinator.applyHybridAttributes(to: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
context.coordinator.performProgrammaticUpdate {
textView.string = text
}
context.coordinator.invalidateStylingCache()
}
let selectedRange = selection.range
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
context.coordinator.setSelection(selectedRange, in: textView)
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var lastStyledEditableRegion: EditableRegion?
private var pendingEdit: DocumentLineIndexEdit?
private var hasUserActivatedEditing = false
private var checklistButtons: [Int: ChecklistOverlayButton] = [:]
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: NSTextView,
shouldChangeTextIn affectedCharRange: NSRange,
replacementString: String?
) -> Bool {
pendingEdit = DocumentLineIndexEdit(
range: affectedCharRange,
replacement: replacementString ?? ""
)
return true
}
func textDidChange(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
hasUserActivatedEditing = true
let selection = EditorSelection(range: textView.selectedRange())
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit, updatedSource: textView.string)
} else {
currentLineIndex = DocumentLineIndex(source: textView.string)
}
parent.onTextEdit(textView.string, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView)
pendingEdit = nil
}
func textViewDidChangeSelection(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
let newSelection = EditorSelection(range: textView.selectedRange())
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView)
parent.selection = newSelection
}
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
let editableRegion = presentationEditableRegion(in: textView)
let activeLineIndex = editableRegion.primaryLineIndex
let invalidationPlan = invalidationPlan(for: textView.string, editableRegion: editableRegion)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange()
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreVisibleOrigin = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor,
usesRenderedControls: true,
editableRegion: editableRegion
)
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
}
lastStyledText = textView.string
lastStyledActiveLineIndex = activeLineIndex
lastStyledEditableRegion = editableRegion
syncChecklistControls(
in: textView,
stylingResult: stylingResult,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex
)
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.string.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreVisibleOrigin
))
}
private func presentationEditableRegion(in textView: NSTextView) -> EditableRegion {
guard hasUserActivatedEditing,
textView.window?.firstResponder === textView
else { return .none() }
return EditableRegion.selection(textView.selectedRange(), in: currentLineIndex)
}
func activateEditingPresentation(in textView: NSTextView) {
guard !hasUserActivatedEditing else { return }
hasUserActivatedEditing = true
applyHybridAttributes(to: textView)
}
func setSelection(_ range: NSRange, in textView: NSTextView) {
guard textView.selectedRange() != range else { return }
performProgrammaticUpdate {
textView.setSelectedRange(range)
textView.scrollRangeToVisible(range)
}
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
lastStyledEditableRegion = nil
hasUserActivatedEditing = false
removeChecklistControls()
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
let previousEditableRegion = lastStyledEditableRegion
let plan = EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
currentActiveLineIndex: editableRegion.primaryLineIndex
)
return plan.includingEditableRegionTransition(
from: previousEditableRegion,
to: editableRegion,
lineCount: currentLineIndex.lineCount
)
}
private func syncChecklistControls(
in textView: NSTextView,
stylingResult: MarkdownTextStylingResult,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int
) {
let shouldRebuildAll = invalidationPlan.isFullRender || invalidationPlan.reason == .sourceChange
let renderedTasks = shouldRebuildAll
? DocumentPresentationState.renderedTasks(in: currentLineIndex, activeLineIndex: activeLineIndex)
: stylingResult.renderedTasks
let tasksByLine = Dictionary(uniqueKeysWithValues: renderedTasks.map { ($0.lineIndex, $0) })
if shouldRebuildAll {
let validLineIndexes = Set(tasksByLine.keys)
for lineIndex in Array(checklistButtons.keys) where !validLineIndexes.contains(lineIndex) {
removeChecklistControl(at: lineIndex)
}
} else {
for lineIndex in stylingResult.styledLineIndexes where tasksByLine[lineIndex] == nil {
removeChecklistControl(at: lineIndex)
}
}
for task in renderedTasks {
let button = checklistButtons[task.lineIndex] ?? ChecklistOverlayButton()
button.task = task
button.onToggle = { [weak self, weak textView, weak button] in
guard let task = button?.task,
let textView
else { return }
self?.toggleTask(task, in: textView)
}
button.state = task.checked ? .on : .off
button.toolTip = task.checked ? "Mark task incomplete" : "Mark task complete"
if button.superview !== textView {
textView.addSubview(button)
}
checklistButtons[task.lineIndex] = button
}
syncChecklistControlFrames(in: textView)
}
func syncChecklistControlFrames(in textView: NSTextView) {
guard !checklistButtons.isEmpty else { return }
for (lineIndex, button) in Array(checklistButtons) {
guard let task = button.task,
let frame = checklistFrame(for: task, in: textView)
else {
removeChecklistControl(at: lineIndex)
continue
}
button.frame = frame
}
}
private func toggleTask(_ task: RenderedTaskElement, in textView: NSTextView) {
guard task.checkboxRange.upperBound <= textView.string.utf16.count else { return }
let preservedSelection = textView.selectedRange()
let wasFirstResponder = textView.window?.firstResponder === textView
let replacement = task.toggledMarkdownCheckbox
let edit = DocumentLineIndexEdit(range: task.checkboxRange, replacement: replacement)
let previousPendingEdit = pendingEdit
pendingEdit = nil
guard textView.shouldChangeText(in: task.checkboxRange, replacementString: replacement) else {
pendingEdit = previousPendingEdit
return
}
performProgrammaticUpdate {
textView.textStorage?.replaceCharacters(in: task.checkboxRange, with: replacement)
textView.setSelectedRange(preservedSelection)
textView.didChangeText()
}
currentLineIndex.replace(edit, updatedSource: textView.string)
let selection = EditorSelection(range: preservedSelection)
parent.onTextEdit(textView.string, edit, selection)
parent.selection = selection
pendingEdit = previousPendingEdit
applyHybridAttributes(to: textView)
if wasFirstResponder {
textView.window?.makeFirstResponder(textView)
}
}
private func checklistFrame(for task: RenderedTaskElement, in textView: NSTextView) -> NSRect? {
guard let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer,
task.checkboxRange.location < textView.string.utf16.count
else { return nil }
let characterRange = NSRange(location: task.checkboxRange.location, length: 1)
layoutManager.ensureLayout(forCharacterRange: characterRange)
let glyphRange = layoutManager.glyphRange(forCharacterRange: characterRange, actualCharacterRange: nil)
guard glyphRange.length > 0 else { return nil }
let glyphRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
let origin = textView.textContainerOrigin
return NSRect(
x: origin.x + glyphRect.minX - 2,
y: origin.y + glyphRect.minY - 1,
width: 18,
height: 18
)
}
private func removeChecklistControl(at lineIndex: Int) {
checklistButtons[lineIndex]?.removeFromSuperview()
checklistButtons.removeValue(forKey: lineIndex)
}
private func removeChecklistControls() {
checklistButtons.values.forEach { $0.removeFromSuperview() }
checklistButtons.removeAll()
}
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
guard let origin,
let scrollView = textView.enclosingScrollView
else { return false }
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
let clampedOrigin = NSPoint(
x: max(0, min(origin.x, maxX)),
y: max(0, min(origin.y, maxY))
)
scrollView.contentView.scroll(to: clampedOrigin)
scrollView.reflectScrolledClipView(scrollView.contentView)
return true
}
}
}
private final class EditorTextView: NSTextView {
var onFocusStateChange: ((NSTextView) -> Void)?
var onUserEditingInteraction: ((NSTextView) -> Void)?
override var acceptsFirstResponder: Bool {
true
}
override func becomeFirstResponder() -> Bool {
let becameFirstResponder = super.becomeFirstResponder()
if becameFirstResponder {
onFocusStateChange?(self)
}
return becameFirstResponder
}
override func resignFirstResponder() -> Bool {
let resignedFirstResponder = super.resignFirstResponder()
if resignedFirstResponder {
onFocusStateChange?(self)
}
return resignedFirstResponder
}
override func mouseDown(with event: NSEvent) {
onUserEditingInteraction?(self)
super.mouseDown(with: event)
}
override func keyDown(with event: NSEvent) {
onUserEditingInteraction?(self)
super.keyDown(with: event)
}
override func paste(_ sender: Any?) {
onUserEditingInteraction?(self)
super.paste(sender)
}
}
private final class ChecklistOverlayButton: NSButton {
var task: RenderedTaskElement?
var onToggle: (() -> Void)?
init() {
super.init(frame: .zero)
setButtonType(.switch)
title = ""
isBordered = false
imagePosition = .imageOnly
cell?.refusesFirstResponder = true
target = self
action = #selector(toggleCheckbox)
}
override var acceptsFirstResponder: Bool {
false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
@objc private func toggleCheckbox() {
onToggle?()
}
}
private final class ComfortableEditorScrollView: NSScrollView {
weak var editorTextView: NSTextView?
var onEditorLayoutInvalidated: ((NSTextView) -> Void)?
private var lastLayoutSize: NSSize = .zero
override func layout() {
super.layout()
let didChangeInsets = updateEditorInsets()
guard let editorTextView else { return }
let layoutSize = contentView.bounds.size
if didChangeInsets || layoutSize != lastLayoutSize {
lastLayoutSize = layoutSize
onEditorLayoutInvalidated?(editorTextView)
}
}
@discardableResult
func updateEditorInsets() -> Bool {
guard let editorTextView else { return false }
let readableWidth: CGFloat = 760
let horizontalInset = max(36, floor((contentView.bounds.width - readableWidth) / 2))
let targetInset = NSSize(width: horizontalInset, height: 38)
if editorTextView.textContainerInset != targetInset {
editorTextView.textContainerInset = targetInset
return true
}
return false
}
}
#if DEBUG
@MainActor
public final class HybridMarkdownLiveEditorHarness {
public private(set) var text: String
public private(set) var selection: EditorSelection
public private(set) var renderPasses: [EditorRenderPassMetric] = []
private let box: StateBox
private let coordinator: NativeMarkdownTextView.Coordinator
private let window: NSWindow
private let scrollView: ComfortableEditorScrollView
private let textView: EditorTextView
public init(
source: String,
selectedRange: NSRange = NSRange(location: 0, length: 0),
initialWidth: CGFloat = 640
) {
let stateBox = StateBox(text: source, selection: EditorSelection(range: selectedRange))
self.text = source
self.selection = EditorSelection(range: selectedRange)
self.box = stateBox
let lineIndex = DocumentLineIndex(source: source)
let parent = NativeMarkdownTextView(
text: Binding(
get: { stateBox.text },
set: { stateBox.text = $0 }
),
selection: Binding(
get: { stateBox.selection },
set: { stateBox.selection = $0 }
),
activeLineIndex: lineIndex.lineIndex(containing: selectedRange.location),
lineIndex: lineIndex,
onTextEdit: { updatedText, edit, updatedSelection in
stateBox.text = updatedText
if let edit {
stateBox.lineIndex.replace(edit, updatedSource: updatedText)
} else {
stateBox.lineIndex = DocumentLineIndex(source: updatedText)
}
if let updatedSelection {
stateBox.selection = updatedSelection
}
},
onRenderPass: { metric in
stateBox.renderPasses.append(metric)
}
)
self.coordinator = parent.makeCoordinator()
self.coordinator.currentLineIndex = lineIndex
self.scrollView = ComfortableEditorScrollView()
self.scrollView.hasVerticalScroller = true
self.scrollView.hasHorizontalScroller = false
self.scrollView.autohidesScrollers = true
self.scrollView.borderType = .noBorder
self.scrollView.drawsBackground = true
self.scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
self.textView = EditorTextView(
frame: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
textContainer: textContainer
)
self.textView.autoresizingMask = [.width]
self.textView.minSize = NSSize(width: 0, height: 480)
self.textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
self.textView.isVerticallyResizable = true
self.textView.isHorizontallyResizable = false
self.textView.onFocusStateChange = { [weak coordinator] textView in
coordinator?.applyHybridAttributes(to: textView)
}
self.textView.onUserEditingInteraction = { [weak coordinator] textView in
coordinator?.activateEditingPresentation(in: textView)
}
self.textView.string = source
self.textView.delegate = coordinator
self.textView.setSelectedRange(selectedRange)
self.textView.isRichText = false
self.textView.isEditable = true
self.textView.isSelectable = true
self.textView.isAutomaticQuoteSubstitutionEnabled = false
self.textView.isAutomaticDashSubstitutionEnabled = false
self.textView.isAutomaticTextReplacementEnabled = false
self.textView.allowsUndo = true
self.textView.usesFindPanel = true
self.textView.isContinuousSpellCheckingEnabled = true
self.textView.backgroundColor = .textBackgroundColor
self.textView.insertionPointColor = .controlAccentColor
self.textView.font = .systemFont(ofSize: 16, weight: .regular)
self.textView.textContainer?.widthTracksTextView = true
self.textView.textContainer?.containerSize = NSSize(width: initialWidth, height: CGFloat.greatestFiniteMagnitude)
self.scrollView.frame = NSRect(x: 0, y: 0, width: initialWidth, height: 480)
self.scrollView.documentView = textView
self.scrollView.editorTextView = textView
self.scrollView.onEditorLayoutInvalidated = { [weak coordinator] textView in
coordinator?.syncChecklistControlFrames(in: textView)
}
self.scrollView.updateEditorInsets()
self.window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: initialWidth, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
self.window.contentView = scrollView
self.coordinator.applyHybridAttributes(to: textView)
syncState()
}
public func simulateLaunchFirstResponder() {
window.makeFirstResponder(textView)
syncState()
}
public func simulateFocusAway() {
window.makeFirstResponder(nil)
syncState()
}
public func setSelection(_ range: NSRange) {
coordinator.activateEditingPresentation(in: textView)
coordinator.setSelection(range, in: textView)
coordinator.textViewDidChangeSelection(Notification(name: NSTextView.didChangeSelectionNotification, object: textView))
syncState()
}
public func clickRenderedCheckbox(lineIndex: Int) {
guard let button = checklistButton(lineIndex: lineIndex) else { return }
button.performClick(nil)
syncState()
}
public func simulateLayout(width: CGFloat) {
window.setContentSize(NSSize(width: width, height: 480))
scrollView.frame = NSRect(x: 0, y: 0, width: width, height: 480)
textView.frame = NSRect(x: 0, y: 0, width: width, height: textView.frame.height)
textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
scrollView.layoutSubtreeIfNeeded()
scrollView.layout()
syncState()
}
public func headingMarkerIsHidden() -> Bool {
isHidden(at: 0)
}
public func characterIsHidden(at location: Int) -> Bool {
isHidden(at: location)
}
public func point(for text: String) -> CGPoint? {
let textRange = (textView.string as NSString).range(of: text)
guard textRange.location != NSNotFound,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer
else { return nil }
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: NSRange(location: textRange.location, length: 1),
actualCharacterRange: nil
)
guard glyphRange.length > 0 else { return nil }
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let glyphLocation = layoutManager.location(forGlyphAt: glyphRange.location)
let origin = textView.textContainerOrigin
return CGPoint(x: origin.x + fragment.minX + glyphLocation.x, y: origin.y + fragment.minY + glyphLocation.y)
}
public func presentationSignature() -> String {
guard let storage = textView.textStorage else { return "" }
return MarkdownPresentationSnapshot.make(
from: storage,
lineIndex: coordinator.currentLineIndex,
containerWidth: textView.textContainer?.containerSize.width ?? 640
).signature
}
public func checklistButtonFrame(lineIndex: Int) -> CGRect? {
checklistButton(lineIndex: lineIndex)?.frame
}
public func checklistAlignmentDelta(lineIndex: Int) -> CGFloat? {
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
else { return nil }
return abs(buttonFrame.midY - labelFrame.midY)
}
public func checklistLabelGap(lineIndex: Int) -> CGFloat? {
guard let buttonFrame = checklistButtonFrame(lineIndex: lineIndex),
let labelFrame = checklistLabelFrame(lineIndex: lineIndex)
else { return nil }
return labelFrame.minX - buttonFrame.maxX
}
public func selectedRange() -> NSRange {
textView.selectedRange()
}
public func source() -> String {
textView.string
}
public func effectiveActiveLineIndex() -> Int {
coordinator.currentLineIndex.lineIndex(containing: textView.selectedRange().location)
}
private func checklistButton(lineIndex: Int) -> ChecklistOverlayButton? {
textView.subviews.compactMap { $0 as? ChecklistOverlayButton }.first { $0.task?.lineIndex == lineIndex }
}
private func checklistLabelFrame(lineIndex: Int) -> CGRect? {
guard let task = checklistButton(lineIndex: lineIndex)?.task,
task.contentRange.location < textView.string.utf16.count,
let layoutManager = textView.layoutManager,
let textContainer = textView.textContainer
else { return nil }
layoutManager.ensureLayout(for: textContainer)
let glyphRange = layoutManager.glyphRange(
forCharacterRange: NSRange(location: task.contentRange.location, length: 1),
actualCharacterRange: nil
)
guard glyphRange.length > 0 else { return nil }
let fragment = layoutManager.lineFragmentRect(forGlyphAt: glyphRange.location, effectiveRange: nil)
let origin = textView.textContainerOrigin
return CGRect(
x: origin.x + fragment.minX,
y: origin.y + fragment.minY,
width: fragment.width,
height: fragment.height
)
}
private func isHidden(at location: Int) -> Bool {
guard let textStorage = textView.textStorage else { return false }
let color = textStorage.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor
let font = textStorage.attribute(.font, at: location, effectiveRange: nil) as? NSFont
return color?.alphaComponent == 0 && (font?.pointSize ?? 0) < 1
}
private func syncState() {
text = box.text
selection = box.selection
renderPasses = box.renderPasses
}
private final class StateBox {
var text: String
var selection: EditorSelection
var lineIndex: DocumentLineIndex
var renderPasses: [EditorRenderPassMetric] = []
init(text: String, selection: EditorSelection) {
self.text = text
self.selection = selection
self.lineIndex = DocumentLineIndex(source: text)
}
}
}
#endif
#elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let lineIndex: DocumentLineIndex
let onTextEdit: (String, DocumentLineIndexEdit?, EditorSelection?) -> Void
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.text = text
textView.delegate = context.coordinator
textView.allowsEditingTextAttributes = false
textView.autocorrectionType = .yes
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.font = .systemFont(ofSize: 17, weight: .regular)
textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
textView.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
context.coordinator.currentLineIndex = lineIndex
if textView.text != text {
context.coordinator.performProgrammaticUpdate {
textView.text = text
}
context.coordinator.invalidateStylingCache()
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
var currentLineIndex: DocumentLineIndex
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var lastStyledEditableRegion: EditableRegion?
private var pendingEdit: DocumentLineIndexEdit?
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
self.currentLineIndex = parent.lineIndex
}
func textView(
_ textView: UITextView,
shouldChangeTextIn range: NSRange,
replacementText text: String
) -> Bool {
pendingEdit = DocumentLineIndexEdit(range: range, replacement: text)
return true
}
func textViewDidChange(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
let selection = EditorSelection(range: textView.selectedRange)
let edit = pendingEdit
if let edit {
currentLineIndex.replace(edit, updatedSource: textView.text)
} else {
currentLineIndex = DocumentLineIndex(source: textView.text)
}
parent.onTextEdit(textView.text, edit, selection)
parent.selection = selection
applyHybridAttributes(to: textView)
pendingEdit = nil
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
let newSelection = EditorSelection(range: textView.selectedRange)
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView)
parent.selection = newSelection
}
func applyHybridAttributes(to textView: UITextView) {
let editableRegion = textView.isFirstResponder
? EditableRegion.selection(textView.selectedRange, in: currentLineIndex)
: .none()
let activeLineIndex = editableRegion.primaryLineIndex
let invalidationPlan = invalidationPlan(for: textView.text, editableRegion: editableRegion)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange
let contentOffset = textView.contentOffset
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreContentOffset = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textView.textStorage,
lineIndex: currentLineIndex,
invalidationPlan: invalidationPlan,
activeLineIndex: activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
textColor: .label,
secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue,
editableRegion: editableRegion
)
if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange
}
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
didRestoreContentOffset = true
}
lastStyledText = textView.text
lastStyledActiveLineIndex = activeLineIndex
lastStyledEditableRegion = editableRegion
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.text.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreContentOffset
))
}
func textViewDidBeginEditing(_ textView: UITextView) {
applyHybridAttributes(to: textView)
}
func textViewDidEndEditing(_ textView: UITextView) {
applyHybridAttributes(to: textView)
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
lastStyledEditableRegion = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String, editableRegion: EditableRegion) -> EditorDirtyLineInvalidationPlan {
let previousEditableRegion = lastStyledEditableRegion
let plan = EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentLineIndex: currentLineIndex,
edit: pendingEdit,
previousActiveLineIndex: previousEditableRegion?.primaryLineIndex ?? lastStyledActiveLineIndex,
currentActiveLineIndex: editableRegion.primaryLineIndex
)
return plan.includingEditableRegionTransition(
from: previousEditableRegion,
to: editableRegion,
lineCount: currentLineIndex.lineCount
)
}
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
let maxX = max(0, textView.contentSize.width - textView.bounds.width)
let maxY = max(0, textView.contentSize.height - textView.bounds.height)
return CGPoint(x: max(0, min(offset.x, maxX)), y: max(0, min(offset.y, maxY)))
}
}
}
#endif
struct MarkdownTextStylingResult {
var totalLineCount: Int
var styledLineCount: Int
var styledLineIndexes: [Int]
var renderedTasks: [RenderedTaskElement]
var editableRegion: EditableRegion
static let empty = MarkdownTextStylingResult(
totalLineCount: 0,
styledLineCount: 0,
styledLineIndexes: [],
renderedTasks: [],
editableRegion: .none()
)
}
enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
#elseif os(iOS)
typealias PlatformColor = UIColor
#endif
@discardableResult
static func apply(
to textStorage: NSTextStorage,
lineIndex: DocumentLineIndex,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
usesRenderedControls: Bool = false,
editableRegion: EditableRegion? = nil
) -> MarkdownTextStylingResult {
let resolvedEditableRegion = editableRegion
?? (activeLineIndex >= 0 ? EditableRegion(lineIndexes: [activeLineIndex]) : .none())
let fullRange = NSRange(location: 0, length: textStorage.length)
guard fullRange.length > 0 else {
return MarkdownTextStylingResult(
totalLineCount: lineIndex.lineCount,
styledLineCount: lineIndex.lineCount,
styledLineIndexes: Array(0..<lineIndex.lineCount),
renderedTasks: [],
editableRegion: resolvedEditableRegion
)
}
textStorage.beginEditing()
if invalidationPlan.isFullRender {
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
}
let presentationState = DocumentPresentationState(
lineIndex: lineIndex,
editableRegion: resolvedEditableRegion,
lineIndexes: invalidationPlan.isFullRender ? nil : invalidationPlan.dirtyLineIndexes
)
var styledLineCount = 0
var styledLineIndexes: [Int] = []
for presentationLine in presentationState.lines {
let line = presentationLine.line
let paragraphRange = presentationRange(for: line, in: lineIndex, textLength: textStorage.length)
resetAttributes(in: textStorage, range: paragraphRange, textColor: textColor)
styledLineCount += 1
styledLineIndexes.append(line.index)
switch presentationLine.state {
case .source:
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 15, weight: .regular)
], range: paragraphRange)
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
case .rendered:
styleRenderedLine(
in: textStorage,
line: line,
paragraphRange: paragraphRange,
renderPlan: presentationLine.renderPlan,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
usesRenderedControls: usesRenderedControls
)
}
}
textStorage.endEditing()
return MarkdownTextStylingResult(
totalLineCount: presentationState.lineCount,
styledLineCount: styledLineCount,
styledLineIndexes: styledLineIndexes,
renderedTasks: presentationState.renderedTasks,
editableRegion: presentationState.editableRegion
)
}
private static func resetAttributes(
in textStorage: NSTextStorage,
range: NSRange,
textColor: PlatformColor
) {
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)
)
}
private static func styleRenderedLine(
in textStorage: NSTextStorage,
line: EditorLine,
paragraphRange: NSRange,
renderPlan: HybridMarkdownLineRenderPlan,
textColor: PlatformColor,
backgroundColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
usesRenderedControls: Bool
) {
guard line.range.length > 0 else { return }
switch renderPlan.kind {
case .heading(let level, let markerRange, let textRange):
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: textRange.location - markerRange.location)
)
textStorage.addAttributes([
.paragraphStyle: headingParagraphStyle(level: level)
], range: paragraphRange)
textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
], range: textRange)
case .blockquote(let markerRange, let contentRange):
textStorage.addAttributes([
.foregroundColor: accentColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.foregroundColor: textColor,
.backgroundColor: accentColor.withAlphaComponent(0.08),
.paragraphStyle: blockquoteParagraphStyle()
], range: paragraphRange)
textStorage.addAttributes([
.font: italicSystemFont(size: 16)
], range: contentRange)
case .horizontalRule(let range):
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.strikethroughStyle: NSUnderlineStyle.thick.rawValue,
.paragraphStyle: horizontalRuleParagraphStyle()
], range: range)
case .unorderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .orderedList(let markerRange, let contentRange, let nestingLevel):
styleListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
contentRange: contentRange,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor
)
case .taskList(let markerRange, let checkboxRange, let contentRange, let checked, let nestingLevel):
styleTaskListLine(
in: textStorage,
paragraphRange: paragraphRange,
markerRange: markerRange,
checkboxRange: checkboxRange,
contentRange: contentRange,
checked: checked,
nestingLevel: nestingLevel,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor,
backgroundColor: backgroundColor,
usesRenderedControls: usesRenderedControls
)
if checked {
textStorage.addAttributes([
.strikethroughStyle: NSUnderlineStyle.single.rawValue,
.foregroundColor: secondaryTextColor
], range: contentRange)
}
case .fencedCodeFence(let markerRange, let languageRange):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
if let languageRange {
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: languageRange.location - markerRange.location)
)
textStorage.addAttributes([
.foregroundColor: accentColor,
.font: monospacedFont(size: 13, weight: .semibold)
], range: languageRange)
} else {
hideSyntax(in: textStorage, range: line.range)
}
case .codeBlockContent(let language):
textStorage.addAttributes(codeBlockAttributes(accentColor: accentColor), range: paragraphRange)
styleCodeSyntax(
in: textStorage,
line: line,
language: language,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
case .tableRow(_, let separatorRanges, let isDivider):
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.06),
.paragraphStyle: tableParagraphStyle()
], range: paragraphRange)
for separatorRange in separatorRanges {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: separatorRange)
}
if isDivider {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: paragraphRange)
}
case .paragraph:
break
}
styleInlineSpans(
in: textStorage,
renderPlan: renderPlan,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
private static func styleInlineSpans(
in textStorage: NSTextStorage,
renderPlan: HybridMarkdownLineRenderPlan,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
for span in renderPlan.spans {
switch span.kind {
case .bold:
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: span.range)
case .italic:
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: span.range)
case .inlineCode:
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.12)
], range: span.range)
case .link, .automaticLink:
textStorage.addAttributes([
.foregroundColor: accentColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
], range: span.range)
case .markdownDelimiter:
hideSyntax(in: textStorage, range: span.range)
}
}
}
private static func 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"
}
}
private static func styleSourceLineMarkers(
in textStorage: NSTextStorage,
line: EditorLine,
secondaryTextColor: PlatformColor
) {
applyRegex("(#{1,6}|\\*\\*|__|\\*|_|`|\\[[ xX]\\]|\\[|\\]|\\(|\\)|\\||>|-{3,}|[-*]|\\d+[.)])", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
}
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 5
return [
.font: systemFont(size: 16, weight: .regular),
.foregroundColor: textColor,
.paragraphStyle: paragraph
]
}
private static func applyRegex(
_ pattern: String,
in textStorage: NSTextStorage,
line: EditorLine,
handler: (NSTextCheckingResult) -> Void
) {
guard line.range.length > 0,
let regex = try? NSRegularExpression(pattern: pattern)
else { return }
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
}
private static func 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)
}
private static func headingFontSize(level: Int) -> CGFloat {
switch level {
case 1: 28
case 2: 23
case 3: 20
default: 17
}
}
private static func headingParagraphStyle(level: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacingBefore = level <= 2 ? 10 : 6
paragraph.paragraphSpacing = level <= 2 ? 9 : 6
return paragraph
}
private static func blockquoteParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 6
paragraph.headIndent = 18
paragraph.firstLineHeadIndent = 0
return paragraph
}
private static func horizontalRuleParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 2
paragraph.paragraphSpacingBefore = 10
paragraph.paragraphSpacing = 10
return paragraph
}
private static func listParagraphStyle(nestingLevel: Int) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
let indent = CGFloat(20 + nestingLevel * 18)
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 4
paragraph.firstLineHeadIndent = 0
paragraph.headIndent = indent
return paragraph
}
private static func tableParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 2
return paragraph
}
private static func codeBlockParagraphStyle() -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
paragraph.paragraphSpacing = 4
paragraph.paragraphSpacingBefore = 2
paragraph.firstLineHeadIndent = 14
paragraph.headIndent = 14
return paragraph
}
private static func codeBlockAttributes(accentColor: PlatformColor) -> [NSAttributedString.Key: Any] {
[
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.08),
.paragraphStyle: codeBlockParagraphStyle()
]
}
private static func styleListLine(
in textStorage: NSTextStorage,
paragraphRange: NSRange,
markerRange: NSRange,
contentRange: NSRange,
nestingLevel: Int,
secondaryTextColor: PlatformColor
) {
textStorage.addAttributes([
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
], range: paragraphRange)
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold)
], range: markerRange)
textStorage.addAttributes([
.font: systemFont(size: 16, weight: .regular)
], range: contentRange)
}
private static func styleTaskListLine(
in textStorage: NSTextStorage,
paragraphRange: NSRange,
markerRange: NSRange,
checkboxRange: NSRange,
contentRange: NSRange,
checked: Bool,
nestingLevel: Int,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor,
backgroundColor: PlatformColor,
usesRenderedControls: Bool
) {
textStorage.addAttributes([
.paragraphStyle: listParagraphStyle(nestingLevel: nestingLevel)
], range: paragraphRange)
hideSyntax(
in: textStorage,
range: NSRange(location: markerRange.location, length: checkboxRange.location - markerRange.location)
)
textStorage.addAttributes([
.foregroundColor: checked ? accentColor : secondaryTextColor,
.font: monospacedFont(size: 15, weight: .semibold),
.backgroundColor: checked ? accentColor.withAlphaComponent(0.16) : backgroundColor
], range: checkboxRange)
if usesRenderedControls {
hideSyntaxPreservingLayout(in: textStorage, range: checkboxRange, backgroundColor: backgroundColor)
}
textStorage.addAttributes([
.font: systemFont(size: 16, weight: .regular)
], range: contentRange)
}
private static func hideSyntax(in textStorage: NSTextStorage, range: NSRange) {
guard range.length > 0 else { return }
textStorage.addAttributes([
.foregroundColor: clearColor(),
.font: monospacedFont(size: 0.1, weight: .regular)
], range: range)
}
private static func hideSyntaxPreservingLayout(
in textStorage: NSTextStorage,
range: NSRange,
backgroundColor: PlatformColor
) {
guard range.length > 0 else { return }
textStorage.addAttributes([
.foregroundColor: clearColor(),
.backgroundColor: backgroundColor,
.font: monospacedFont(size: 10, weight: .regular)
], range: range)
}
#if os(macOS)
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> NSFont {
NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask)
}
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
}
private static func clearColor() -> NSColor {
.clear
}
private static func codeKeywordColor(accentColor: NSColor) -> NSColor {
.systemPurple
}
private static func codeStringColor(accentColor: NSColor) -> NSColor {
.systemRed
}
private static func codeNumberColor(accentColor: NSColor) -> NSColor {
.systemOrange
}
#elseif os(iOS)
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> UIFont {
UIFont.italicSystemFont(ofSize: size)
}
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
}
private static func clearColor() -> UIColor {
.clear
}
private static func codeKeywordColor(accentColor: UIColor) -> UIColor {
.systemPurple
}
private static func codeStringColor(accentColor: UIColor) -> UIColor {
.systemRed
}
private static func codeNumberColor(accentColor: UIColor) -> UIColor {
.systemOrange
}
#endif
}
private extension NSRange {
var upperBound: Int {
location + length
}
}
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}