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 var activeColumnNumber: Int { guard lines.indices.contains(activeLineIndex) else { return 1 } let activeLine = lines[activeLineIndex] let offset = selection.location - activeLine.range.location return max(0, min(offset, activeLine.range.length)) + 1 } 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.. 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 }