From bd54714fa00eda309f3c448aeba1f21d96987495 Mon Sep 17 00:00:00 2001 From: Feror Date: Fri, 29 May 2026 17:52:49 +0200 Subject: [PATCH] docs(decisions): record editor technology selection --- .../SaplingEditor/EditorArchitecture.swift | 192 ++++++++++++++++++ decisions.md | 44 ++++ 2 files changed, 236 insertions(+) create mode 100644 Sources/SaplingEditor/EditorArchitecture.swift diff --git a/Sources/SaplingEditor/EditorArchitecture.swift b/Sources/SaplingEditor/EditorArchitecture.swift new file mode 100644 index 0000000..7c7a63f --- /dev/null +++ b/Sources/SaplingEditor/EditorArchitecture.swift @@ -0,0 +1,192 @@ +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.. 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 +} diff --git a/decisions.md b/decisions.md index 92dd4a3..423eba1 100644 --- a/decisions.md +++ b/decisions.md @@ -350,3 +350,47 @@ Starting with macOS reduces complexity and accelerates development. Architecture should remain cross-platform where practical. Product decisions should optimize for desktop workflows first. + +--- + +# D-013 — Editor Technology Selection + +Date: 2026-05 + +Status: Accepted Provisionally + +Review After: Milestone 2 + +## Decision + +Sapling will use native platform text systems for the editor prototype: + +- NSTextView on macOS +- UITextView on iOS + +These views will be wrapped behind a Sapling editor abstraction. + +SwiftUI TextEditor will not be used as the primary editor implementation. + +## Rationale + +Sapling's hybrid Markdown editor requires advanced control over cursor movement, selection state, layout, attributed rendering, and editing behavior. + +SwiftUI TextEditor is useful for simple text entry, but it does not expose enough low-level editing hooks to validate the active-line source and inactive-line rendered model cleanly. + +NSTextView and UITextView provide direct access to TextKit, attributed text storage, selection ranges, delegates, layout managers, and platform editing behaviors. That makes them better foundations for Milestone 1 validation. + +## Consequences + +Positive: + +- The prototype can inspect and control selection ranges directly. +- Line-level styling and rendering experiments can be performed in-place. +- The app can preserve native editing behavior while testing hybrid Markdown concepts. +- The implementation can remain SwiftUI at the application layer. + +Negative: + +- AppKit and UIKit bridging adds platform-specific code. +- The editor abstraction must prevent the rest of the app from depending directly on NSTextView or UITextView. +- A future custom editor engine may still be required if line replacement or overlay rendering cannot preserve cursor correctness.