Sapling/Sources/SaplingEditor/HybridMarkdownEditor.swift

542 lines
19 KiB
Swift
Raw Normal View History

import Foundation
2026-05-29 15:19:33 +02:00
import SwiftUI
import SaplingCore
import SaplingRenderer
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
2026-05-29 15:19:33 +02:00
@MainActor
public final class HybridMarkdownEditorViewModel: ObservableObject, EditorCoordinator {
@Published public private(set) var state: EditorState
2026-05-29 15:19:33 +02:00
public init(document: MarkdownDocument, activeLineIndex: Int = 0) {
self.state = EditorState(
document: EditorDocument(markdownDocument: document),
activeLineIndex: activeLineIndex
)
2026-05-29 15:19:33 +02:00
}
public var document: MarkdownDocument {
state.document.markdownDocument
2026-05-29 15:19:33 +02:00
}
public var hasUnsavedChanges: Bool {
state.hasUnsavedChanges
2026-05-29 15:19:33 +02:00
}
public var activeLineIndex: Int {
state.activeLineIndex
2026-05-29 15:19:33 +02:00
}
public func replaceDocument(_ document: EditorDocument) {
state = EditorState(document: document)
}
public func updateSource(_ source: String) {
state.updateSource(source)
}
public func updateSelection(_ selection: EditorSelection) {
state.updateSelection(selection)
}
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
}
}
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
}
public var state: EditorState {
viewModel.state
}
2026-05-29 15:19:33 +02:00
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
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
EditorStatusBar(
activeLineIndex: viewModel.state.activeLineIndex,
lineCount: viewModel.state.lines.count,
hasUnsavedChanges: viewModel.state.hasUnsavedChanges,
activeLinePreview: activeLinePreview
)
2026-05-29 15:19:33 +02:00
}
.background(platformTextBackground)
}
private var activeLinePreview: AttributedString {
guard viewModel.state.lines.indices.contains(viewModel.state.activeLineIndex) else {
return AttributedString()
}
return renderer.inlineMarkdown(for: viewModel.state.lines[viewModel.state.activeLineIndex].source)
}
2026-05-29 15:19:33 +02:00
}
private struct EditorStatusBar: View {
let activeLineIndex: Int
let lineCount: Int
let hasUnsavedChanges: Bool
let activeLinePreview: AttributedString
2026-05-29 15:19:33 +02:00
var body: some View {
HStack(spacing: 12) {
Text("Line \(activeLineIndex + 1) of \(lineCount)")
Text(hasUnsavedChanges ? "Modified" : "Saved")
.foregroundStyle(hasUnsavedChanges ? .orange : .secondary)
Spacer()
Text(activeLinePreview)
.lineLimit(1)
.foregroundStyle(.secondary)
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
}
}
#if os(macOS)
private struct NativeMarkdownTextView: NSViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
guard let textView = scrollView.documentView as? NSTextView else {
return scrollView
}
textView.delegate = context.coordinator
textView.string = text
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.allowsUndo = true
textView.usesFindPanel = true
textView.isContinuousSpellCheckingEnabled = true
textView.textContainerInset = NSSize(width: 20, height: 18)
textView.backgroundColor = .textBackgroundColor
textView.insertionPointColor = .controlAccentColor
textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: CGFloat.greatestFiniteMagnitude
)
context.coordinator.applyHybridAttributes(to: 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 {
textView.string = text
}
let selectedRange = selection.range
if textView.selectedRange() != selectedRange,
selectedRange.location <= textView.string.utf16.count {
textView.setSelectedRange(selectedRange)
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, NSTextViewDelegate {
var parent: NativeMarkdownTextView
private var isApplyingAttributes = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard !isApplyingAttributes,
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 let textView = notification.object as? NSTextView else { return }
parent.selection = EditorSelection(range: textView.selectedRange())
applyHybridAttributes(to: textView)
}
func applyHybridAttributes(to textView: NSTextView) {
guard let textStorage = textView.textStorage else { return }
isApplyingAttributes = true
let selectedRange = textView.selectedRange()
MarkdownTextStyler.apply(
to: textStorage,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .textBackgroundColor,
activeLineBackgroundColor: .controlAccentColor.withAlphaComponent(0.10),
textColor: .labelColor,
secondaryTextColor: .secondaryLabelColor,
accentColor: .controlAccentColor
)
textView.setSelectedRange(selectedRange)
isApplyingAttributes = false
}
}
}
#elseif os(iOS)
private struct NativeMarkdownTextView: UIViewRepresentable {
@Binding var text: String
@Binding var selection: EditorSelection
let activeLineIndex: Int
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 = .monospacedSystemFont(ofSize: 15, weight: .regular)
textView.textContainerInset = UIEdgeInsets(top: 18, left: 16, bottom: 18, right: 16)
textView.backgroundColor = .systemBackground
context.coordinator.applyHybridAttributes(to: textView)
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.parent = self
if textView.text != text {
textView.text = text
}
context.coordinator.applyHybridAttributes(to: textView)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: NativeMarkdownTextView
private var isApplyingAttributes = false
init(_ parent: NativeMarkdownTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: UITextView) {
guard !isApplyingAttributes else { return }
parent.text = textView.text
parent.selection = EditorSelection(range: textView.selectedRange)
applyHybridAttributes(to: textView)
}
func textViewDidChangeSelection(_ textView: UITextView) {
parent.selection = EditorSelection(range: textView.selectedRange)
applyHybridAttributes(to: textView)
}
func applyHybridAttributes(to textView: UITextView) {
isApplyingAttributes = true
let selectedRange = textView.selectedRange
MarkdownTextStyler.apply(
to: textView.textStorage,
activeLineIndex: parent.activeLineIndex,
backgroundColor: .systemBackground,
activeLineBackgroundColor: .systemBlue.withAlphaComponent(0.10),
textColor: .label,
secondaryTextColor: .secondaryLabel,
accentColor: .systemBlue
)
textView.selectedRange = selectedRange
isApplyingAttributes = false
}
}
}
#endif
private enum MarkdownTextStyler {
#if os(macOS)
typealias PlatformColor = NSColor
#elseif os(iOS)
typealias PlatformColor = UIColor
#endif
static func apply(
to textStorage: NSTextStorage,
activeLineIndex: Int,
backgroundColor: PlatformColor,
activeLineBackgroundColor: PlatformColor,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
let source = textStorage.string as NSString
let fullRange = NSRange(location: 0, length: source.length)
guard fullRange.length > 0 else { return }
textStorage.beginEditing()
textStorage.setAttributes(baseAttributes(textColor: textColor), range: fullRange)
for line in lines(in: source as String) {
if line.index == activeLineIndex {
textStorage.addAttributes([
.backgroundColor: activeLineBackgroundColor,
.font: monospacedFont(size: 14, weight: .regular)
], range: line.range)
styleSourceLineMarkers(in: textStorage, line: line, secondaryTextColor: secondaryTextColor)
} else {
styleRenderedLine(
in: textStorage,
line: line,
source: source,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
2026-05-29 15:19:33 +02:00
}
}
textStorage.endEditing()
}
private static func styleRenderedLine(
in textStorage: NSTextStorage,
line: EditorLine,
source: NSString,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
guard line.range.length > 0 else { return }
let rawLine = source.substring(with: line.range)
let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
if let heading = headingPrefixRange(in: rawLine, lineRange: line.range) {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 12, weight: .regular)
], range: heading.markerRange)
textStorage.addAttributes([
.font: systemFont(size: headingFontSize(level: heading.level), weight: .semibold)
], range: heading.textRange)
return
}
if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("- [ ] ") {
textStorage.addAttributes([
.foregroundColor: secondaryTextColor,
.font: monospacedFont(size: 13, weight: .regular)
], range: NSRange(location: line.range.location, length: min(6, line.range.length)))
}
styleInlineMarkdown(
in: textStorage,
line: line,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
accentColor: accentColor
)
}
private static func styleInlineMarkdown(
in textStorage: NSTextStorage,
line: EditorLine,
textColor: PlatformColor,
secondaryTextColor: PlatformColor,
accentColor: PlatformColor
) {
applyRegex("\\*\\*([^*]+)\\*\\*", in: textStorage, line: line) { match in
guard match.numberOfRanges > 1 else { return }
textStorage.addAttributes([.font: systemFont(size: 14, weight: .semibold)], range: match.range(at: 1))
markdownDelimiterRanges(match.range, leading: 2, trailing: 2).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
}
}
applyRegex("(?<!\\*)\\*([^*]+)\\*(?!\\*)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 1 else { return }
textStorage.addAttributes([.font: italicSystemFont(size: 14)], range: match.range(at: 1))
markdownDelimiterRanges(match.range, leading: 1, trailing: 1).forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
2026-05-29 15:19:33 +02:00
}
}
applyRegex("\\[([^\\]]+)\\]\\(([^\\)]+)\\)", in: textStorage, line: line) { match in
guard match.numberOfRanges > 2 else { return }
textStorage.addAttributes([
.foregroundColor: accentColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
], range: match.range(at: 1))
let markerRanges = [
NSRange(location: match.range.location, length: 1),
NSRange(location: match.range(at: 1).upperBound, length: 2),
NSRange(location: match.range(at: 2).upperBound, length: 1)
]
markerRanges.forEach {
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: $0)
2026-05-29 15:19:33 +02:00
}
textStorage.addAttributes([.foregroundColor: secondaryTextColor], range: match.range(at: 2))
2026-05-29 15:19:33 +02:00
}
}
private static func styleSourceLineMarkers(
in textStorage: NSTextStorage,
line: EditorLine,
secondaryTextColor: PlatformColor
) {
applyRegex("(```|#{1,6}|\\*\\*|\\*|\\[[ xX]\\]|\\[|\\]|\\(|\\))", 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 = 3
return [
.font: systemFont(size: 14, weight: .regular),
.foregroundColor: textColor,
.paragraphStyle: paragraph
]
}
private static func lines(in source: String) -> [EditorLine] {
EditorState(document: EditorDocument(url: URL(fileURLWithPath: "/dev/null"), title: "", source: source)).lines
}
private static func headingPrefixRange(
in rawLine: String,
lineRange: NSRange
) -> (level: Int, markerRange: NSRange, textRange: NSRange)? {
let markerCount = rawLine.prefix { $0 == "#" }.count
guard (1...6).contains(markerCount),
rawLine.dropFirst(markerCount).first == " "
else { return nil }
let textOffset = markerCount + 1
return (
markerCount,
NSRange(location: lineRange.location, length: markerCount),
NSRange(location: lineRange.location + textOffset, length: max(0, lineRange.length - textOffset))
)
}
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 markdownDelimiterRanges(
_ fullRange: NSRange,
leading: Int,
trailing: Int
) -> [NSRange] {
[
NSRange(location: fullRange.location, length: leading),
NSRange(location: fullRange.upperBound - trailing, length: trailing)
]
}
private static func headingFontSize(level: Int) -> CGFloat {
2026-05-29 15:19:33 +02:00
switch level {
case 1: 24
case 2: 20
case 3: 18
default: 15
2026-05-29 15:19:33 +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)
}
#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
}
2026-05-29 15:19:33 +02:00
}
private var platformTextBackground: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}