Files
lanework/Kanban/UI/Card/CardBodyMode.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

161 lines
7.9 KiB
Swift

import Observation
import SwiftUI
// MARK: - The mode
/// Which of the body column's two surfaces is showing (05-card-window.md ▸ Mode grammar).
///
/// **Two cases, not three.** The raw-source outlet swaps the *entire content area* — title, body
/// and sidebar — so it is a state of the window, not of the body column, and it does not belong in
/// this enum. Edit Body disabling while raw source is active (11-command-nexus.md) is that
/// window-level state's rule to enforce over this one.
public enum CardBodyMode: Equatable, Sendable {
/// The rendered, selectable preview — **the resting state**.
case preview
/// The raw-Markdown editor.
case edit
/// The mode a window opens its body in: **Preview, unless the body is empty** (05 ▸ Mode
/// grammar: "a card opens in Preview — unless its body is empty, which opens straight into
/// Edit with the cursor ready (a new card has nothing to preview, so ⌘↩ during creation flows
/// title → body without a mode stop)").
///
/// Whitespace is empty (`BodyMarkup.isEmpty`): a body holding one newline previews as a blank
/// page, and stopping the user at a blank preview of a blank body is precisely the ceremony
/// the rule removes.
public static func opening(body: String) -> CardBodyMode {
BodyMarkup.isEmpty(body) ? .edit : .preview
}
/// ⌘E — View ▸ Edit Body's checkmark toggle. Also the whole of "Return in Preview enters Edit"
/// and "Escape in Edit returns to Preview": three gestures, one flip, so they cannot drift.
public var toggled: CardBodyMode {
self == .preview ? .edit : .preview
}
}
// MARK: - The window's body surface, as a handle
/// One card window's body column, reduced to what things *outside* it need: which mode it is in,
/// and how to put a find bar over whichever surface currently holds the keyboard.
///
/// `BoardSearchPresentation`'s shape and for its reason — one per window, `@State` in the host,
/// published through the focus system so a **menu item** (Edit ▸ Find, ⌘F) can reach the frontmost
/// card window without anyone keeping a which-window-is-key register. It is deliberately not on
/// `BoardStore`: the store is the *board's*, shared by every window on it, and two card windows
/// open on two cards of one board are in two different modes.
@MainActor
@Observable
public final class CardBodyPresentation {
/// The surface showing right now. Starts in Preview and is settled by `openIfNeeded(body:)`
/// the first time the window has a body to judge.
public var mode: CardBodyMode = .preview
/// Puts the standard find bar over the focused body surface — **Edit ▸ Find (⌘F) is
/// find-in-text here** (05 ▸ Preview; 11-command-nexus.md scopes ⌘F "Board window: board
/// search; card window: find-in-text"). Filled in by the surface itself, which is the only
/// thing that holds a text view to hand the action to; `nil` until one exists, which is also
/// exactly when ⌘F has nothing to find in.
public var findInText: (() -> Void)?
/// Flushes the Edit buffer — **"leaving Edit flushes the debounce"** (05 ▸ Mode grammar), and
/// the reason the flip goes through `setMode(_:)` rather than being three separate assignments.
///
/// Filled in by the window with its edit session's `endEditSession()`. It hangs here rather than
/// on the session because *this* is the type every path that leaves Edit already holds: the menu
/// item's toggle, Escape in the editor, and Return in Preview all flip the mode through one
/// object, so attaching the flush to the flip is what makes "always" true by construction rather
/// than by three call sites remembering.
public var flushEdits: (() -> Void)?
/// Whether the opening rule has already run for this window.
///
/// **Once, not per snapshot.** The rule is about *opening* a card, and the body it judges
/// arrives with the first snapshot — but snapshots keep arriving (a watcher reload, a lane
/// move, another window's edit). Re-running it would drag a reader back into Edit the moment
/// someone else emptied the file, and would fight a user who had just pressed ⌘E.
private var hasOpened = false
public init() {}
/// Applies the opening rule the first time it is called, and does nothing on every call after.
@discardableResult
public func openIfNeeded(body: String) -> CardBodyMode {
guard !hasOpened else { return mode }
hasOpened = true
mode = CardBodyMode.opening(body: body)
return mode
}
/// ⌘E, Return in Preview, Escape in Edit — see `CardBodyMode.toggled`.
public func toggleMode() {
setMode(mode.toggled)
}
/// The one place the mode changes, and therefore the one place **leaving Edit flushes** (05 ▸
/// Mode grammar: "Leaving Edit flushes the debounce (mode flip, raw-source entry, window close)
/// — the preview never lags the text that produced it, and neither does disk").
///
/// The flush runs *before* the flip, not after: Preview reads the same buffer the editor was
/// writing, so a flip that rendered first and saved second would be indistinguishable on screen
/// — but a failure in that order would leave the user reading text the app had just failed to
/// save, with the mode already changed under them. Saving first means the banner (and, on a
/// close, the modal) arrives while the editor is still the thing on screen.
///
/// Setting the mode it already has does nothing at all, which is what keeps a redundant
/// menu-item validation pass or a re-published focus value from flushing an untouched buffer.
public func setMode(_ newMode: CardBodyMode) {
guard newMode != mode else { return }
if mode == .edit { flushEdits?() }
mode = newMode
}
}
// MARK: - View ▸ Edit Body
/// View ▸ Edit Body (⌘E) — the body column's mode toggle, with checkmark state (11-command-nexus.md;
/// 05-card-window.md ▸ Mode grammar).
///
/// **A `Toggle`, because the row is a checkmark row**: 11 files it as "(checkmark toggle)", and
/// 04-interactions.md ▸ Configurable bindings requires that such a row keep "one stable title,
/// checkmark state only" — so the title is the same string it was while the row was disabled, and
/// what changed with this milestone is the validation and the action, exactly as `FutureCommands`
/// predicts.
///
/// Validation is scope: with no card window in front there is no `cardBody` focused value, and the
/// row disables. The read-only lock is deliberately **not** part of it — entering Edit is not a
/// mutation, and 02-architecture.md § the lock's scope keeps editor buffers alive under the lock
/// (only their saves suspend), so a locked board can still be read in the editor and its text
/// copied out.
///
// m6-raw-source: "View ▸ Edit Body (⌘E) disables while source mode is active, matching its toolbar
// item" (05 ▸ Raw source outlet). That is one more clause on `isDisabled` once a window-level raw
// mode exists to read; the row, its title and its chord do not move.
struct EditBodyCommand: View {
@FocusedValue(\.cardBody) private var cardBody
var body: some View {
Toggle("Edit Body", isOn: Binding(
get: { cardBody?.mode == .edit },
set: { isOn in cardBody?.setMode(isOn ? .edit : .preview) }
))
.keyboardShortcut("e", modifiers: .command)
.disabled(cardBody == nil)
}
}
/// The focused card window's body column, beside `FocusedValues.boardSearch` — see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardBodyKey: FocusedValueKey {
typealias Value = CardBodyPresentation
}
extension FocusedValues {
var cardBody: CardBodyPresentation? {
get { self[FocusedCardBodyKey.self] }
set { self[FocusedCardBodyKey.self] = newValue }
}
}