Build Edit mode with debounced, byte-honest saves

The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 10:46:02 -04:00
parent 6dc84176fb
commit e989c1f26e
16 changed files with 2195 additions and 78 deletions
+229 -17
View File
@@ -4,7 +4,7 @@ import SwiftUI
// MARK: - CardBodySurface
/// The body column's text surface: **one hosted `NSTextView` serving both modes** the rendered
/// Preview and, until the Edit card lands, the read-only raw-Markdown placeholder.
/// Preview and the raw-Markdown Edit editor.
///
/// ### Why AppKit, and not `Text().textSelection(.enabled)`
///
@@ -28,20 +28,37 @@ import SwiftUI
///
/// ### One substrate, two modes
///
/// Preview and the Edit placeholder differ only in the attributed string they are handed
/// (`BodyMarkupRenderer.attributedString` vs `.rawText`). That is deliberate: it means F, text
/// selection and copying behave identically on both surfaces without either one implementing them,
/// and it leaves the Edit card a seam whose shape is already known make this view editable, give
/// it a debounced save, and swap `.rawText` for a highlighting pass.
/// Preview and Edit differ in three things and nothing else: whether the view is editable, what it
/// is handed (a rendered attributed string, or the raw text under a highlighting pass), and which
/// key means "flip". Everything else F, selection, copying, the find bar, the scroll position
/// belongs to the substrate and is therefore identical in both, without either mode implementing it.
///
/// **One view rather than two representables**, deliberately: `CardBodyPresentation.findInText` holds
/// a closure over *this* text view, and two views swapping across a mode flip would race to own it
/// F would work or not depending on the order SwiftUI happened to mount them in. One view has one
/// text view for the window's life, and the flip is a reconfiguration.
///
/// ### What the editor writes, and when
///
/// Nothing here writes to disk. The text view reports every change to `CardBodyEditSession`, which
/// owns the ~700 ms debounce, the three write gates and the flush; this file's whole responsibility
/// is that the buffer and the view agree, and that the view never has text replaced under the user's
/// cursor (the view half of dirty-buffer-wins the session's half is `adopt(diskBody:)`).
struct CardBodySurface: NSViewRepresentable {
/// The card's body, verbatim the source both renderings are made from.
/// The text to show: `CardBodyEditSession.text`, which is the buffer in Edit and because a
/// clean buffer follows disk the card's body in Preview. One string for both modes is what
/// makes "the preview never lags the text that produced it" (05 Mode grammar) fall out rather
/// than need arranging.
let body: String
let mode: CardBodyMode
/// The card's own folder: what relative images and links resolve against.
let cardFolder: URL?
/// The window's body handle this view fills in its `findInText`.
let presentation: CardBodyPresentation
/// The buffer this surface edits. Keystrokes go in through `edited(_:)`; nothing else here
/// touches it.
let session: CardBodyEditSession
/// Whether a checkbox click may write. `false` under the read-only lock, where "the controls
/// disable in place" (05 Preview; 02-architecture.md § the lock's scope).
let isTaskToggleEnabled: Bool
@@ -63,7 +80,7 @@ struct CardBodySurface: NSViewRepresentable {
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
let textView = NSTextView(frame: .zero, textContainer: container)
let textView = CardBodyTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = false
textView.isSelectable = true
@@ -74,12 +91,21 @@ struct CardBodySurface: NSViewRepresentable {
textView.autoresizingMask = NSView.AutoresizingMask.width
textView.minSize = CGSize(width: 0, height: 0)
textView.maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
// Nothing about a card body is the app's to rewrite as the user reads it.
// Nothing about a card body is the app's to rewrite as the user reads it or as they type
// it: "the text is the raw Markdown, character for character no hidden transforms, no
// smart substitutions" (05 Edit) is exactly this list, and in Edit it is normative rather
// than merely tidy. A smart quote substituted into a fenced code block would be the app
// silently corrupting the user's file.
textView.isAutomaticLinkDetectionEnabled = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.isAutomaticDataDetectionEnabled = false
textView.smartInsertDeleteEnabled = false
// Z is the editor's own undo (05 Edit). `allowsUndo` turns it on; the *session* scoping is
// the coordinator's `undoManager(for:)`, below.
textView.allowsUndo = true
// The renderer already coloured links and checkboxes; the only thing the text view should
// add is the pointer, so the two do not fight over the run's appearance.
let linkAttributes: [NSAttributedString.Key: Any] = [.cursor: NSCursor.pointingHand]
@@ -91,6 +117,15 @@ struct CardBodySurface: NSViewRepresentable {
let gutter = CardWindowMetrics.gutter(bodyPointSize: CardWindowMetrics.bodyPointSize)
textView.textContainerInset = CGSize(width: gutter, height: gutter)
// The two fixed keys of the mode grammar, on the surface that owns the keyboard while they
// are pressed: "Return in Preview also enters Edit Escape in Edit returns to Preview"
// (05 Mode grammar). They are the text view's rather than a SwiftUI `.onKeyPress` because
// the text view *is* the first responder in both modes a key handler above it would only
// see what the editor declined to eat.
let presentation = presentation
textView.onReturnInPreview = { presentation.setMode(.edit) }
textView.onEscapeInEdit = { presentation.setMode(.preview) }
let scrollView = NSScrollView()
scrollView.documentView = textView
scrollView.hasVerticalScroller = true
@@ -100,13 +135,13 @@ struct CardBodySurface: NSViewRepresentable {
scrollView.findBarPosition = .aboveContent
context.coordinator.textView = textView
context.coordinator.session = session
context.coordinator.onToggleTask = onToggleTask
context.coordinator.isTaskToggleEnabled = isTaskToggleEnabled
// Deferred one turn: `makeNSView` runs inside SwiftUI's update, and `presentation` is
// observed by a menu item (Edit Find), so writing to it here would be a mutation during
// an update of the very graph that reads it.
let presentation = presentation
Task { @MainActor [weak textView] in
presentation.findInText = { [weak textView] in
guard let textView else { return }
@@ -127,9 +162,15 @@ struct CardBodySurface: NSViewRepresentable {
let coordinator = context.coordinator
coordinator.onToggleTask = onToggleTask
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
coordinator.session = session
guard let textView = scrollView.documentView as? NSTextView else { return }
guard let textView = scrollView.documentView as? CardBodyTextView else { return }
let pointSize = CardWindowMetrics.bodyPointSize
if coordinator.mode != mode {
coordinator.enter(mode, in: textView)
}
let key = Coordinator.RenderKey(body: body, mode: mode, cardFolder: cardFolder, pointSize: pointSize)
// **Rebuild only when the inputs changed.** SwiftUI re-runs this on every unrelated state
// change in the window; re-laying out the whole body each time would throw away the scroll
@@ -137,17 +178,21 @@ struct CardBodySurface: NSViewRepresentable {
guard coordinator.rendered != key else { return }
coordinator.rendered = key
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
let content: NSAttributedString = switch mode {
case .preview: BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
case .edit: BodyMarkupRenderer.rawText(body, context: context)
switch mode {
case .preview:
let context = BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: cardFolder)
textView.textStorage?.setAttributedString(
BodyMarkupRenderer.attributedString(for: BodyMarkup.parse(body), context: context)
)
case .edit:
coordinator.show(body, in: textView, pointSize: pointSize)
}
textView.textStorage?.setAttributedString(content)
}
// MARK: - Coordinator
/// The delegate, and the render cache.
/// The delegate, the render cache, and the editor's session-scoped undo.
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
@@ -161,9 +206,117 @@ struct CardBodySurface: NSViewRepresentable {
weak var textView: NSTextView?
var rendered: RenderKey?
var session: CardBodyEditSession?
var onToggleTask: ((Int, Bool) -> Void)?
var isTaskToggleEnabled = true
/// Which mode the view is currently *configured* for `nil` until the first update, which is
/// what makes the initial configuration a mode entry like any other (and is why a card that
/// opens straight into Edit gets the caret without a special case).
private(set) var mode: CardBodyMode?
/// **The editor's own undo manager, and the whole of "session-scoped"** (05 Edit: "Z here
/// is the text view's own undo session-scoped, ending when the editor loses focus or the
/// mode flips").
///
/// Without this the text view would use the *window's* undo manager, whose stack outlives
/// every mode flip and is shared with anything else in the window that registers an
/// undoable action so Z after leaving Edit could reach back into text the user had
/// already committed. Owning one here makes the scoping structural: `removeAllActions()` at
/// the two moments 05 names is then an emptying of a stack nothing else can see.
private let editorUndoManager = UndoManager()
/// Set while this coordinator is replacing the view's text, so the resulting change
/// notification is not mistaken for typing.
private var isSettingText = false
// MARK: Mode
/// Reconfigures the view for a mode the only place editability, the undo stack and first
/// responder change.
func enter(_ newMode: CardBodyMode, in textView: CardBodyTextView) {
mode = newMode
// The session's undo stack ends with the mode, per 05. Emptied on the way *in* as well
// as out, so an Edit session never opens on top of the previous one's actions.
editorUndoManager.removeAllActions()
switch newMode {
case .preview:
textView.isEditable = false
case .edit:
textView.isEditable = true
textView.typingAttributes = MarkdownHighlighter.baseAttributes(
pointSize: CardWindowMetrics.bodyPointSize
)
// "**Empty body opens in Edit** with the cursor ready" (05 Mode grammar) and the
// same courtesy for a deliberate E, which is a request to type. Deferred a turn:
// this runs inside a SwiftUI update, and making a view first responder re-enters
// AppKit's responder machinery.
Task { @MainActor [weak textView] in
guard let textView, textView.isEditable else { return }
textView.window?.makeFirstResponder(textView)
}
}
}
/// Puts `text` in the editor and highlights it **never replacing what the user is looking
/// at unless it actually differs**.
///
/// The equality guard is load-bearing rather than an optimization: this runs on every
/// keystroke (the buffer changed, so SwiftUI re-ran the update), and replacing the storage
/// with the string it already holds would collapse the selection, scroll the view, and throw
/// away the undo stack on every character typed.
func show(_ text: String, in textView: CardBodyTextView, pointSize: CGFloat) {
guard let storage = textView.textStorage else { return }
if storage.string != text {
// A foreign edit arriving under a *clean* buffer, or the first fill of the editor.
// The selection is preserved where it still fits; a caret past the new end clamps
// rather than disappearing.
let selected = textView.selectedRange()
// The undo stack described text that no longer exists an agent or a hand edit
// replaced it and Z restoring a run of it would be this app inventing a merge.
editorUndoManager.removeAllActions()
isSettingText = true
storage.setAttributedString(NSAttributedString(
string: text,
attributes: MarkdownHighlighter.baseAttributes(pointSize: pointSize)
))
isSettingText = false
let length = (text as NSString).length
textView.setSelectedRange(NSRange(
location: min(selected.location, length),
length: min(selected.length, max(0, length - min(selected.location, length)))
))
}
MarkdownHighlighter.highlight(storage, pointSize: pointSize)
textView.typingAttributes = MarkdownHighlighter.baseAttributes(pointSize: pointSize)
}
// MARK: NSTextViewDelegate
/// The editor's undo manager see `editorUndoManager`.
func undoManager(for view: NSTextView) -> UndoManager? {
editorUndoManager
}
/// Every keystroke, straight into the buffer. The session decides what that costs: a
/// restarted debounce, or a cancelled one when the change happened to restore the file's own
/// text.
func textDidChange(_ notification: Notification) {
guard !isSettingText, mode == .edit, let textView = notification.object as? NSTextView else { return }
session?.edited(textView.string)
}
/// Focus leaving the editor ends the undo session (05 Edit), and is *not* a save: the
/// debounce is still running and will land on its own, which is what keeps clicking into the
/// sidebar from being a commit point the design never named.
func textDidEndEditing(_ notification: Notification) {
editorUndoManager.removeAllActions()
}
/// The click grammar, in one method: a checkbox writes, anything else opens, and the return
/// value is always `true` so the text view never falls back to its own link handling.
func textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
@@ -194,3 +347,62 @@ struct CardBodySurface: NSViewRepresentable {
}
}
}
// MARK: - CardBodyTextView
/// The body surface's text view, subclassed for exactly two keys.
///
/// Return in Preview and Escape in Edit are **fixed grammar, not menu items** (05-card-window.md
/// Mode grammar: "Return in Preview also enters Edit the board's edit key applied to the body;
/// fixed grammar like the board's Return, not a menu item"), so they have to be intercepted where
/// the keyboard actually is. Both are guarded by editability, which is the mode: a Return in Edit is
/// a newline like any other, and an Escape in Preview means nothing here.
final class CardBodyTextView: NSTextView {
var onReturnInPreview: (() -> Void)?
var onEscapeInEdit: (() -> Void)?
/// Preview is not editable, so AppKit would send this nowhere the mode's own key handling has
/// to come before `super`, which for a read-only text view merely beeps.
override func keyDown(with event: NSEvent) {
let isPlainReturn = event.keyCode == 36 || event.keyCode == 76
let modifiers = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.function, .numericPad, .capsLock])
if !isEditable, isPlainReturn, modifiers.isEmpty {
onReturnInPreview?()
return
}
super.keyDown(with: event)
}
/// Escape. Intercepted before `NSTextView`'s own meaning for it (text completion), and only
/// while editing with the find bar up the bar is first responder and never reaches this.
override func cancelOperation(_ sender: Any?) {
guard isEditable, let onEscapeInEdit else {
super.cancelOperation(sender)
return
}
onEscapeInEdit()
}
/// **A file drop is never the editor's** (05-card-window.md Attachments, settled: "file drops
/// import as attachments anywhere in the window Edit mode included, the text editor never
/// intercepts a file drop; dragged *text* lands in the Edit editor at the caret").
///
/// An editable, rich `NSTextView` would otherwise happily take a dragged file and turn it into a
/// path or an attachment cell inside the user's Markdown. Dropping the *file* types from what
/// this view accepts lets that drag fall through to the window, which is where the attachment
/// import belongs. Every text type a plain-text drag, a URL dragged out of a browser is left
/// exactly as AppKit offers it, so the other half of the rule is the default behaviour rather
/// than a re-implementation of it.
///
// m6-card-attachments: the window-level drop surface that catches what this declines.
override var acceptableDragTypes: [NSPasteboard.PasteboardType] {
let fileTypes: Set<NSPasteboard.PasteboardType> = [
.fileURL,
// The Carbon-era name AppKit still puts on a Finder drag alongside the modern one.
NSPasteboard.PasteboardType("NSFilenamesPboardType")
]
return super.acceptableDragTypes.filter { !fileTypes.contains($0) }
}
}