Files
lanework/Kanban/UI/Card/CardBodySurface.swift
T
rzen e989c1f26e 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
2026-07-28 10:46:02 -04:00

409 lines
21 KiB
Swift

import AppKit
import SwiftUI
// MARK: - CardBodySurface
/// The body column's text surface: **one hosted `NSTextView` serving both modes** — the rendered
/// Preview and the raw-Markdown Edit editor.
///
/// ### Why AppKit, and not `Text(…).textSelection(.enabled)`
///
/// Four of Preview's settled rules are things a SwiftUI `Text` cannot do, and each of them is
/// normative rather than nice-to-have:
///
/// - **⌘F is find-in-text here** (05-card-window.md ▸ Preview; 11-command-nexus.md scopes ⌘F to
/// find-in-text in the card window). The standard find bar is `NSTextFinder` over a text view in
/// a scroll view; SwiftUI's text selection offers no find at all, and a hand-rolled search UI
/// would be a second, worse find bar in an app whose whole posture is to use the system's.
/// - **Clicking never edits, but a checkbox does something.** A text view already hit-tests
/// characters, already distinguishes a click from a selection drag, and already reports the one
/// it decided on to its delegate. That machinery is exactly the "selectable everywhere, live in
/// one place" grammar, and re-deriving it from a SwiftUI gesture over a `Text` would mean
/// re-deriving text selection.
/// - **Links open things.** `.link` attributes plus `textView(_:clickedOnLink:at:)` is the whole of
/// "external URLs open in the browser; relative links open the target with its default app".
/// - **Tables.** `NSTextTable`'s automatic layout *is* the browser sizing rule the design names,
/// and it is a TextKit 1 construct — which is why the stack below is built by hand rather than
/// taken from `NSTextView(frame:)`, whose modern default is TextKit 2.
///
/// ### One substrate, two modes
///
/// 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 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
/// Byte offset and the state the user saw — straight through to `BoardStore.toggleTaskMarker`.
let onToggleTask: (Int, Bool) -> Void
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> NSScrollView {
// TextKit 1, explicitly: `NSTextView(frame:)` would give a TextKit 2 stack, in which
// `NSTextTable` does not lay out. Building the stack by hand is the supported way to ask
// for the older one, and it is the only reason this is not a one-line construction.
let storage = NSTextStorage()
let layoutManager = NSLayoutManager()
storage.addLayoutManager(layoutManager)
let container = NSTextContainer(size: CGSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
container.widthTracksTextView = true
layoutManager.addTextContainer(container)
let textView = CardBodyTextView(frame: .zero, textContainer: container)
textView.delegate = context.coordinator
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = true
textView.drawsBackground = false
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
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 — 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]
textView.linkTextAttributes = linkAttributes
textView.displaysLinkToolTips = true
textView.usesFindBar = true
textView.isIncrementalSearchingEnabled = true
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
scrollView.hasHorizontalScroller = false
scrollView.autohidesScrollers = true
scrollView.drawsBackground = false
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.
Task { @MainActor [weak textView] in
presentation.findInText = { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
// `performTextFinderAction` takes its verb from the sender's `tag`, which is how
// the standard Edit ▸ Find menu item drives it; a menu item made for the purpose
// says the same thing from a closure.
let sender = NSMenuItem()
sender.tag = NSTextFinder.Action.showFindInterface.rawValue
textView.performTextFinderAction(sender)
}
}
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
let coordinator = context.coordinator
coordinator.onToggleTask = onToggleTask
coordinator.isTaskToggleEnabled = isTaskToggleEnabled
coordinator.session = session
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
// position and the selection — the reader's place in a document they are reading.
guard coordinator.rendered != key else { return }
coordinator.rendered = key
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)
}
}
// MARK: - Coordinator
/// The delegate, the render cache, and the editor's session-scoped undo.
@MainActor
final class Coordinator: NSObject, NSTextViewDelegate {
/// What the text view currently shows, as the inputs that produced it.
struct RenderKey: Equatable {
let body: String
let mode: CardBodyMode
let cardFolder: URL?
let pointSize: CGFloat
}
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 {
guard let url = Self.url(from: link) else { return false }
if let task = CardBodyLink.parseTask(url) {
// Disabled in place under the read-only lock: the click is swallowed rather than
// attempted, because a write that would be refused should not post a banner the
// standing lock row already explains.
guard isTaskToggleEnabled else { return true }
onToggleTask?(task.offset, task.isChecked)
return true
}
// External URLs go to the browser and relative ones — already resolved to file URLs by
// the renderer — go to their default app. `NSWorkspace.open` is both of those sentences
// (05 ▸ Preview ▸ Links).
NSWorkspace.shared.open(url)
return true
}
private static func url(from link: Any) -> URL? {
switch link {
case let url as URL: url
case let string as String: URL(string: string)
default: nil
}
}
}
}
// 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) }
}
}