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 } } }