docs(decisions): record editor technology selection

This commit is contained in:
Feror 2026-05-29 17:52:49 +02:00
parent 5b57df0214
commit bd54714fa0
2 changed files with 236 additions and 0 deletions

View file

@ -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..<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
}

View file

@ -350,3 +350,47 @@ Starting with macOS reduces complexity and accelerates development.
Architecture should remain cross-platform where practical. Architecture should remain cross-platform where practical.
Product decisions should optimize for desktop workflows first. 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.