Files
lanework/Kanban/UI/Card/CardBodyMode.swift
T
rzen 40c0a75c24 Build the Raw Source outlet
The escape hatch: View > Raw Source (opt-cmd-E) unmounts the whole
content area for the literal on-disk index.md in a plain monospaced
editor with Cancel/Apply. Raw source is window-level state, not a third
body mode — entry rides setMode(.preview), which flushes the Edit
session by construction, then reads the file fresh; exit reveals
Preview, and an empty body after Apply does not reopen Edit (openIfNeeded
already ran). Apply validates the proposed bytes through the loader's
own card checks — parseDocument's strict UTF-8/BOM rejection, schema,
order — deliberately skipping the uneditable-shape refusal, since a
flow-mapping card is exactly what the hatch repairs; invalid bytes
alert in place with the loader's own error and no bracket opens. The
write is byte-for-byte with no modified stamp and no modified-by clear,
per 01's explicit carve-out — the verbatim contract outranks stamping —
and identical bytes write nothing. Escape cancels, cmd-Return applies,
toggle-off applies too, and cmd-E disables while raw is active via a
testable predicate. Tombstoned targets refuse as vanished: a foreign
delete is never reverted by a stale buffer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 11:25:25 -04:00

174 lines
9.0 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. It lives in `CardRawSourceSession`, which is also where the grammar's two open
/// questions are settled (which mode a raw exit lands in, and what an empty body after Apply does).
/// Edit Body disabling while raw source is active (11-command-nexus.md) is that window-level state's
/// rule over this one, and it is enforced on the row: `EditBodyCommand.isEnabled(body:rawSource:)`.
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 **plus the raw-source clause**: with no card window in front there is no
/// `cardBody` focused value, and the row disables; with source mode active it disables too — "View ▸
/// Edit Body (⌘E) disables while source mode is active, matching its toolbar item" (05 ▸ Raw source
/// outlet; 11-command-nexus.md files the same clause on the row). The reason is that the two would
/// be editing the same bytes from two surfaces: while the whole `index.md` is open as text, a mode
/// flip in the body column beneath it has nothing to flip *to* — the column is not on screen — and
/// its buffer's next debounced save would write a body the raw buffer is also about to overwrite.
/// Cancel and Apply own the exits (03-board-ui.md ▸ Toolbar).
///
/// 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.
struct EditBodyCommand: View {
@FocusedValue(\.cardBody) private var cardBody
@FocusedValue(\.cardRawSource) private var rawSource
/// The row's validation, as a value a test can hold: a menu item's `.disabled` is otherwise only
/// observable by driving the menu bar, and "⌘E disables while raw source is active" is precisely
/// the kind of clause that regresses silently.
static func isEnabled(body: CardBodyPresentation?, rawSource: CardRawSourceSession?) -> Bool {
body != nil && rawSource?.isActive != true
}
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(!Self.isEnabled(body: cardBody, rawSource: rawSource))
}
}
/// 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 }
}
}