Sapling/Sources/SaplingEditor/EditorArchitecture.swift

193 lines
5.5 KiB
Swift
Raw Normal View History

import Foundation
import SaplingCore
public enum EditorLineMode: String, Hashable, Codable, Sendable {
case source
case rendered
}
public protocol EditorView {
var state: EditorState { get }
}
public struct EditorDocument: Identifiable, Hashable, Codable, Sendable {
public var id: UUID
public var url: URL
public var title: String
public var source: String
public var lastSavedSource: String
public init(
id: UUID = UUID(),
url: URL,
title: String,
source: String,
lastSavedSource: String? = nil
) {
self.id = id
self.url = url
self.title = title
self.source = source
self.lastSavedSource = lastSavedSource ?? source
}
public init(markdownDocument: MarkdownDocument) {
self.init(
id: markdownDocument.id,
url: markdownDocument.url,
title: markdownDocument.title,
source: markdownDocument.content
)
}
public var hasUnsavedChanges: Bool {
source != lastSavedSource
}
public var markdownDocument: MarkdownDocument {
MarkdownDocument(id: id, url: url, title: title, content: source)
}
}
public struct EditorLine: Identifiable, Hashable, Sendable {
public var id: Int { index }
public var index: Int
public var source: String
public var range: NSRange
public var mode: EditorLineMode
public init(index: Int, source: String, range: NSRange, mode: EditorLineMode) {
self.index = index
self.source = source
self.range = range
self.mode = mode
}
}
public struct EditorSelection: Hashable, Codable, Sendable {
public var location: Int
public var length: Int
public init(location: Int = 0, length: Int = 0) {
self.location = max(0, location)
self.length = max(0, length)
}
public init(range: NSRange) {
self.init(location: range.location, length: range.length)
}
public var range: NSRange {
NSRange(location: location, length: length)
}
}
public struct EditorState: Hashable, Sendable {
public var document: EditorDocument
public var lines: [EditorLine]
public var selection: EditorSelection
public var activeLineIndex: Int
public init(
document: EditorDocument,
selection: EditorSelection = EditorSelection(),
activeLineIndex: Int = 0
) {
self.document = document
self.selection = selection
self.activeLineIndex = activeLineIndex
self.lines = Self.makeLines(
from: document.source,
activeLineIndex: activeLineIndex
)
}
public var hasUnsavedChanges: Bool {
document.hasUnsavedChanges
}
public mutating func updateSource(_ source: String) {
document.source = source
activeLineIndex = Self.lineIndex(containing: selection.location, in: source)
lines = Self.makeLines(from: source, activeLineIndex: activeLineIndex)
}
public mutating func updateSelection(_ selection: EditorSelection) {
self.selection = selection
activeLineIndex = Self.lineIndex(containing: selection.location, in: document.source)
lines = Self.makeLines(from: document.source, activeLineIndex: activeLineIndex)
}
public mutating func markSaved() {
document.lastSavedSource = document.source
}
private static func makeLines(from source: String, activeLineIndex: Int) -> [EditorLine] {
var lines: [EditorLine] = []
var lineStart = source.startIndex
var utf16Location = 0
var index = 0
while lineStart < source.endIndex {
let lineEnd = source[lineStart...].firstIndex(of: "\n") ?? source.endIndex
let line = String(source[lineStart..<lineEnd])
let length = line.utf16.count
lines.append(EditorLine(
index: index,
source: line,
range: NSRange(location: utf16Location, length: length),
mode: index == activeLineIndex ? .source : .rendered
))
if lineEnd == source.endIndex {
lineStart = lineEnd
utf16Location += length
} else {
lineStart = source.index(after: lineEnd)
utf16Location += length + 1
}
index += 1
}
if source.isEmpty || source.hasSuffix("\n") {
lines.append(EditorLine(
index: index,
source: "",
range: NSRange(location: utf16Location, length: 0),
mode: index == activeLineIndex ? .source : .rendered
))
}
return lines
}
private static func lineIndex(containing location: Int, in source: String) -> Int {
let clampedLocation = max(0, min(location, source.utf16.count))
var currentLocation = 0
for (index, line) in source.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
let length = line.utf16.count
if clampedLocation <= currentLocation + length {
return index
}
currentLocation += length + 1
}
return 0
}
}
public protocol EditorRenderer: Sendable {
func renderedLine(for line: EditorLine) -> AttributedString
}
@MainActor
public protocol EditorCoordinator: AnyObject {
var state: EditorState { get }
func replaceDocument(_ document: EditorDocument)
func updateSource(_ source: String)
func updateSelection(_ selection: EditorSelection)
func save() throws
}