Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

765 lines
28 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 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 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,
onRenderPass: viewModel.recordRenderPass
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
columnNumber: viewModel.state.activeColumnNumber,
lineCount: viewModel.state.lines.count,
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 onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = ComfortableEditorScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.borderType = .noBorder
scrollView.drawsBackground = true
scrollView.backgroundColor = .textBackgroundColor
let textStorage = NSTextStorage()
let layoutManager = NSLayoutManager()
let textContainer = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
textContainer.widthTracksTextView = true
textContainer.heightTracksTextView = false
layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager)
let textView = EditorTextView(frame: scrollView.contentView.bounds, textContainer: textContainer)
textView.autoresizingMask = [.width]
textView.minSize = NSSize(width: 0, height: scrollView.contentSize.height)
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.delegate = context.coordinator
textView.string = text
textView.isRichText = false
textView.isEditable = true
textView.isSelectable = true
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.allowsUndo = true
textView.usesFindPanel = true
textView.isContinuousSpellCheckingEnabled = true
textView.backgroundColor = .textBackgroundColor
textView.insertionPointColor = .controlAccentColor
textView.font = .systemFont(ofSize: 16, weight: .regular)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
scrollView.documentView = textView
scrollView.editorTextView = textView
scrollView.updateEditorInsets()
context.coordinator.applyHybridAttributes(to: textView)
context.coordinator.requestInitialFocus(for: textView)
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.parent = self
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
context.coordinator.performProgrammaticUpdate {
textView.string = text
}
context.coordinator.invalidateStylingCache()
}
let selectedRange = selection.range
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
context.coordinator.setSelection(selectedRange, in: textView)
}
context.coordinator.applyHybridAttributes(to: textView)
context.coordinator.requestInitialFocus(for: textView)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var didFocusTextView = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
parent.text = textView.string
parent.selection = EditorSelection(range: textView.selectedRange())
applyHybridAttributes(to: textView)
}
func textViewDidChangeSelection(_ notification: Notification) {
guard !isPerformingProgrammaticUpdate else { return }
guard let textView = notification.object as? NSTextView else { return }
let newSelection = EditorSelection(range: textView.selectedRange())
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView)
parent.selection = newSelection
}
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
let invalidationPlan = invalidationPlan(for: textView.string)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange()
let visibleOrigin = textView.enclosingScrollView?.contentView.bounds.origin
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreVisibleOrigin = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textStorage,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
didRestoreVisibleOrigin = restoreVisibleOrigin(visibleOrigin, in: textView)
}
lastStyledText = textView.string
lastStyledActiveLineIndex = parent.activeLineIndex
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.string.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: parent.activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreVisibleOrigin
))
}
func setSelection(_ range: NSRange, in textView: NSTextView) {
guard textView.selectedRange() != range else { return }
performProgrammaticUpdate {
textView.setSelectedRange(range)
textView.scrollRangeToVisible(range)
}
}
func requestInitialFocus(for textView: NSTextView) {
guard !didFocusTextView else { return }
DispatchQueue.main.async { [weak self, weak textView] in
guard let self,
let textView,
let window = textView.window,
!self.didFocusTextView
else { return }
if window.firstResponder !== textView {
window.makeFirstResponder(textView)
}
self.didFocusTextView = window.firstResponder === textView
}
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
}
private func restoreVisibleOrigin(_ origin: NSPoint?, in textView: NSTextView) -> Bool {
guard let origin,
let scrollView = textView.enclosingScrollView
else { return false }
let maxY = max(0, textView.bounds.height - scrollView.contentView.bounds.height)
let maxX = max(0, textView.bounds.width - scrollView.contentView.bounds.width)
let clampedOrigin = NSPoint(
x: max(0, min(origin.x, maxX)),
y: max(0, min(origin.y, maxY))
)
scrollView.contentView.scroll(to: clampedOrigin)
scrollView.reflectScrolledClipView(scrollView.contentView)
return true
}
}
}
private final class EditorTextView: NSTextView {
override var acceptsFirstResponder: Bool {
true
}
}
private final class ComfortableEditorScrollView: NSScrollView {
weak var editorTextView: NSTextView?
override func layout() {
super.layout()
updateEditorInsets()
}
func updateEditorInsets() {
guard let editorTextView else { return }
let readableWidth: CGFloat = 760
let horizontalInset = max(36, floor((contentView.bounds.width - readableWidth) / 2))
let targetInset = NSSize(width: horizontalInset, height: 38)
if editorTextView.textContainerInset != targetInset {
editorTextView.textContainerInset = targetInset
}
}
}
#elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
let onRenderPass: (EditorRenderPassMetric) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.text = text
textView.allowsEditingTextAttributes = false
textView.autocorrectionType = .yes
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.font = .systemFont(ofSize: 17, weight: .regular)
textView.textContainerInset = UIEdgeInsets(top: 28, left: 24, bottom: 28, right: 24)
textView.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView)
context.coordinator.requestInitialFocus(for: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
if textView.text != text {
context.coordinator.performProgrammaticUpdate {
textView.text = text
}
context.coordinator.invalidateStylingCache()
}
context.coordinator.applyHybridAttributes(to: textView)
context.coordinator.requestInitialFocus(for: textView)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
private var programmaticUpdateDepth = 0
private var lastStyledText: String?
private var lastStyledActiveLineIndex: Int?
private var didFocusTextView = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange)
applyHybridAttributes(to: textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
guard !isPerformingProgrammaticUpdate else { return }
let newSelection = EditorSelection(range: textView.selectedRange)
guard parent.selection != newSelection else { return }
applyHybridAttributes(to: textView)
parent.selection = newSelection
}
func applyHybridAttributes(to textView: UITextView) {
let invalidationPlan = invalidationPlan(for: textView.text)
guard invalidationPlan.requiresStyling else { return }
let selectedRange = textView.selectedRange
let contentOffset = textView.contentOffset
let start = Date()
var stylingResult = MarkdownTextStylingResult.empty
var didRestoreContentOffset = false
performProgrammaticUpdate {
stylingResult = MarkdownTextStyler.apply(
to: textView.textStorage,
invalidationPlan: invalidationPlan,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
textColor: .label,
secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue
)
if textView.selectedRange != selectedRange,
selectedRange.location <= textView.text.utf16.count {
textView.selectedRange = selectedRange
}
textView.setContentOffset(clampedContentOffset(contentOffset, in: textView), animated: false)
didRestoreContentOffset = true
}
lastStyledText = textView.text
lastStyledActiveLineIndex = parent.activeLineIndex
parent.onRenderPass(EditorRenderPassMetric(
reason: invalidationPlan.reason,
durationMilliseconds: Date().timeIntervalSince(start) * 1000,
characterCount: textView.text.utf16.count,
lineCount: stylingResult.totalLineCount,
dirtyLineCount: stylingResult.styledLineCount,
activeLineIndex: parent.activeLineIndex,
isFullRender: invalidationPlan.isFullRender,
restoredScrollPosition: didRestoreContentOffset
))
}
func performProgrammaticUpdate(_ updates: () -> Void) {
programmaticUpdateDepth += 1
defer {
programmaticUpdateDepth -= 1
}
updates()
}
func requestInitialFocus(for textView: UITextView) {
guard !didFocusTextView else { return }
DispatchQueue.main.async { [weak self, weak textView] in
guard let self,
let textView,
textView.window != nil,
!self.didFocusTextView
else { return }
self.didFocusTextView = textView.becomeFirstResponder()
}
}
func invalidateStylingCache() {
lastStyledText = nil
lastStyledActiveLineIndex = nil
}
private var isPerformingProgrammaticUpdate: Bool {
programmaticUpdateDepth > 0
}
private func invalidationPlan(for text: String) -> EditorDirtyLineInvalidationPlan {
EditorDirtyLineInvalidator.plan(
previousText: lastStyledText,
currentText: text,
previousActiveLineIndex: lastStyledActiveLineIndex,
currentActiveLineIndex: parent.activeLineIndex
)
}
private func clampedContentOffset(_ offset: CGPoint, in textView: UITextView) -> CGPoint {
let maxX = max(0, textView.contentSize.width - textView.bounds.width)
let maxY = max(0, textView.contentSize.height - textView.bounds.height)
return CGPoint(x: max(0, min(offset.x, maxX)), y: max(0, min(offset.y, maxY)))
}
}
}
#endif
struct MarkdownTextStylingResult {
var totalLineCount: Int
var styledLineCount: Int
static let empty = MarkdownTextStylingResult(totalLineCount: 0, styledLineCount: 0)
}
enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
#elseif os(iOS)
typealias PlatformColor = UIColor
#endif
@discardableResult
static func apply(
to textStorage: NSTextStorage,
invalidationPlan: EditorDirtyLineInvalidationPlan,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) -> MarkdownTextStylingResult {
let source = textStorage.string as NSString
let fullRange = NSRange(location: 0, length: source.length)
let lines = EditorActiveLineTracker.lines(from: source as String, activeLineIndex: activeLineIndex)
guard fullRange.length > 0 else {
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: lines.count)
}
textStorage.beginEditing()
if invalidationPlan.isFullRender {
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
}
let renderer = HybridMarkdownLineRenderer()
let dirtyLineIndexes = Set(invalidationPlan.dirtyLineIndexes)
var styledLineCount = 0
for line in lines {
guard invalidationPlan.isFullRender || dirtyLineIndexes.contains(line.index) else {
continue
}
resetAttributes(in: textStorage, line: line, textColor: textColor)
styledLineCount += 1
if line.index == activeLineIndex {
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 15, weight: .regular)
], range: line.range)
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
} else {
styleRenderedLine(
in: textStorage,
line: line,
renderPlan: renderer.renderPlan(for: line),
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
}
textStorage.endEditing()
return MarkdownTextStylingResult(totalLineCount: lines.count, styledLineCount: styledLineCount)
}
private static func resetAttributes(
in textStorage: NSTextStorage,
line: EditorLine,
textColor: PlatformColor
) {
guard line.range.length > 0 else { return }
textStorage.setAttributes(baseAttributes(textColor: textColor), range: line.range)
}
private static func styleRenderedLine(
in textStorage: NSTextStorage,
line: EditorLine,
renderPlan: HybridMarkdownLineRenderPlan,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
guard line.range.length > 0 else { return }
if case .heading(let level, let markerRange, let textRange) = renderPlan.kind {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 13, weight: .regular)
], range: markerRange)
textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: level), weight: .semibold)
], range: textRange)
}
styleInlineSpans(
in: textStorage,
renderPlan: renderPlan,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
private static func styleInlineSpans(
in textStorage: NSTextStorage,
renderPlan: HybridMarkdownLineRenderPlan,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
for span in renderPlan.spans {
switch span.kind {
case .bold:
textStorage.addAttributes([.font: systemFont(size: 16, weight: .semibold)], range: span.range)
case .italic:
textStorage.addAttributes([.font: italicSystemFont(size: 16)], range: span.range)
case .inlineCode:
textStorage.addAttributes([
.font: monospacedFont(size: 15, weight: .regular),
.backgroundColor: accentColor.withAlphaComponent(0.12)
], range: span.range)
case .markdownDelimiter:
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: span.range)
}
}
}
private static func styleSourceLineMarkers(
in textStorage: NSTextStorage,
line: EditorLine,
secondaryTextColor: PlatformColor
) {
applyRegex("(#{1,6}|\\*\\*|\\*|`)", in: textStorage, line: line) { match in
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range)
}
}
private static func baseAttributes(textColor: PlatformColor) -> [NSAttributedString.Key: Any] {
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
paragraph.paragraphSpacing = 5
return [
.font: systemFont(size: 16, weight: .regular),
.foregroundColor: textColor,
.paragraphStyle: paragraph
]
}
private static func applyRegex(
_ pattern: String,
in textStorage: NSTextStorage,
line: EditorLine,
handler: (NSTextCheckingResult) -> Void
) {
guard line.range.length > 0,
let regex = try? NSRegularExpression(pattern: pattern)
else { return }
regex.matches(in: textStorage.string, range: line.range).forEach(handler)
}
private static func headingFontSize(level: Int) -> CGFloat {
switch level {
case 1: 28
case 2: 23
case 3: 20
default: 17
}
}
#if os(macOS)
private static func systemFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> NSFont {
NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask)
}
private static func monospacedFont(size: CGFloat, weight: NSFont.Weight) -> NSFont {
NSFont.monospacedSystemFont(ofSize: size, weight: weight)
}
#elseif os(iOS)
private static func systemFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.systemFont(ofSize: size, weight: weight)
}
private static func italicSystemFont(size: CGFloat) -> UIFont {
UIFont.italicSystemFont(ofSize: size)
}
private static func monospacedFont(size: CGFloat, weight: UIFont.Weight) -> UIFont {
UIFont.monospacedSystemFont(ofSize: size, weight: weight)
}
#endif
}
private extension NSRange {
var upperBound: Int {
location + length
}
}
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}