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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user