import Foundation import Observation // MARK: - Liveness /// Which side of the live/tombstoned boundary something sits on. /// /// An item-referencing set is **homogeneous by liveness** (04-interactions.md § The trash): it never /// mixes live and tombstoned items, so the side is a property of the set as a whole rather than of /// each member — which is exactly what makes re-resolution across a reload a matching rule rather /// than a partition. public enum Liveness: Sendable, Equatable { case live case trashed /// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are /// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see /// their doc comments in `BoardModel.swift`. init(isDeleted: Bool) { self = isDeleted ? .trashed : .live } } // MARK: - ItemReferenceSet /// A set of UUIDs over the snapshot plus the liveness side it lives on — **the one shape every /// piece of transient state that points at items wears**: the selection, drag membership, and the /// pending cut are three values of this type, not three hand-rolled near-copies /// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that /// references items (drag state, pending cut) — is a set of UUIDs over the snapshot"). /// /// **UUIDs, never indices or copies of items.** A reload swaps the whole snapshot as a value, and /// anything holding positions or item copies would be silently wrong the moment an agent files a /// card. /// /// ### One rule, applied in two directions /// /// `constrained(to:)` *is* the rule — **state may only reference items in the current universe** — /// and everything else here is that primitive with a universe supplied. Only the universe differs /// between the two callers: /// /// - **Reload survival**: the universe is the new snapshot's ids on this set's liveness side, which /// is what `resolved(against:)` computes before delegating. /// - **The live search filter**: the universe is the visible ids the predicate produced, so /// 04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is /// this one, with a different universe (m5 wires that caller). /// /// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and /// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to /// know about snapshots, and the whole point of the value-type snapshot is that nothing has to. public struct ItemReferenceSet: Sendable, Equatable { public var ids: Set public var liveness: Liveness public init(ids: Set = [], liveness: Liveness = .live) { self.ids = ids self.liveness = liveness } /// Nothing referenced, on the live side — the state a board opens in, the state a drag with /// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to. public static let empty = ItemReferenceSet() public var isEmpty: Bool { ids.isEmpty } /// This set narrowed to `universe`: members that are in it, **side unchanged**. /// /// The primitive both directions are built from — intersection and nothing else. It is /// deliberately ignorant of what a universe *is*: a snapshot's ids on one liveness side /// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as /// the rule is concerned, which is what lets one rule be stated once and mean both. /// /// The liveness side survives even when the membership does not: an emptied set is still a set /// on a side, and re-populating it (a fresh click, a new drag) is the caller's business. public func constrained(to universe: Set) -> ItemReferenceSet { guard !ids.isEmpty else { return self } return ItemReferenceSet(ids: ids.intersection(universe), liveness: liveness) } /// This set re-grounded on `snapshot`: the members that are still there, **on the same liveness /// side**, and nothing else. /// /// Two rules, both settled in 02-architecture.md § Live-reload resilience: /// /// - **Vanished members leave silently.** No substitute is invented, no successor is picked — /// an empty result is a legitimate outcome. (App-mediated deletion is deliberately different: /// ⌫ selects the successor sibling, because that is an act rather than a surprise — /// 04-interactions.md ▸ The map. That belongs to the delete command, not here.) /// - **A liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or /// restores a selected tombstoned one — ejects it, keeping 04-interactions.md's /// homogeneous-by-liveness invariant true across reloads so menu validation never sees a /// mixed selection. The pending cut inherits the same rule for free (04 ▸ Clipboard: "a cut /// item that is tombstoned or vanishes externally before paste drops out of the pending /// cut"), and so does drag membership (04 ▸ Drag and drop's emptied-drag rule). /// /// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as /// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its /// cards from a live set even though their own flags never changed — the card renders nowhere /// once 03-board-ui.md collapses the lane to a single trash entry, and nothing invisible may /// stay selected, drag-included, or pending-cut. public func resolved(against snapshot: BoardModel) -> ItemReferenceSet { guard !ids.isEmpty else { return self } return constrained(to: Self.idUniverse(of: snapshot, on: liveness)) } /// Every id in `snapshot` whose **effective** liveness is `side` — the reload direction's /// universe, and the only place the ancestor walk lives. /// /// A lane contributes itself on the side its own flag names, and each of its cards on the side /// `lane.isDeleted || card.isDeleted` names — the walk being one level deep is the whole of it, /// because the tree is (01-storage-format.md § Fractal layout: board → lane → card). static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set { var universe: Set = [] for lane in snapshot.lanes { if Liveness(isDeleted: lane.isDeleted) == side { universe.insert(lane.id) } for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side { universe.insert(card.id) } } return universe } } // MARK: - NewCardPlaceholder /// The card being created: an inline editor rendered as a pseudo-card **overlaid** on the snapshot, /// with no disk presence and no UUID until the title commits — 02-architecture.md § Layering's *one /// named exception* to the one-way flow, and the only thing in `TransientBoardState` that is not a /// set of ids. /// /// **Anchored to a lane, never to an item set**, and that asymmetry is the point: an unborn card /// has no identity to re-resolve, so the only question a reload can ask about it is whether the /// place it is being born into still exists. `TransientBoardState.resolve(against:)` owns the /// answer; see its doc comment for the two discard rules. public struct NewCardPlaceholder: Sendable, Equatable { /// How far along the birth is — and therefore what a reload has to check. public enum Phase: Sendable, Equatable { /// The inline editor is open and the draft is in flight. Nothing has been written; abandoning /// (Escape, empty commit, click-away) discards it and disk was never touched /// (04-interactions.md ▸ Grammar). case editing /// The Writer's create ran, so the real card's id is minted — but the watcher has not /// round-tripped it yet, and until it does the snapshot has no such card. The overlay stands /// in for it across that gap ("the placeholder stays visible until the real card arrives, /// then hands off"), which is what keeps the one-way flow from showing a hole where the user /// just typed a title. case awaitingArrival(ItemID) } /// The lane the card is being created in — the anchor, and the only item identity a placeholder /// has before its title commits. public var laneID: ItemID /// What the user has typed so far. Lives here and nowhere else: there is no file to hold it. public var draftTitle: String public var phase: Phase public init(laneID: ItemID, draftTitle: String = "", phase: Phase = .editing) { self.laneID = laneID self.draftTitle = draftTitle self.phase = phase } } // MARK: - TransientBoardState /// Everything a board's windows share that is **not on disk** — one per `BoardStore`, created with /// it and dying with it (02-architecture.md § Changes from Kanban, settled m3). /// /// Its reason for existing is that the store's transient state was becoming a grab-bag. Every member /// below is filed by **how a reload treats it**, and there are exactly three kinds plus a remainder: /// /// 1. **Item-referencing sets** — `selection`, `dragMembers`, `pendingCut`. One shape /// (`ItemReferenceSet`) and one constraint rule, *members must exist in the current universe*. /// `resolve(against:)` applies it to each of them **independently**: a card vanishing from the /// selection has no business disturbing a drag in flight or a pending cut, and independence is /// the only way that stays true without three orders of operations to reason about. /// 2. **Derived state, stored as its inputs only** — `searchQuery` is kept; its *result set* is /// deliberately absent. The filter is a live predicate re-run against each new snapshot /// (04-interactions.md § Search), so a card an agent files mid-search appears the moment the /// reload lands and a card edited to no longer match animates out. A stored result set would be /// a second, staler answer to a question the snapshot can always answer, and would need a /// re-resolution rule of its own — which is exactly the accretion this type exists to stop. /// 3. **The overlay** — `newCardPlaceholder`, anchored to a lane rather than to items, discarded /// when the lane it is anchored to goes away and handed off when the real card arrives. /// /// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never /// persisted** — visiting the trash is an errand, not a layout choice. It needs no reset logic /// because this object is built fresh with its store; closing the board is the reset. /// /// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there; /// `@Observable` so the board window and its card windows re-render off the same truth. @MainActor @Observable public final class TransientBoardState { // MARK: Item-referencing sets /// What the user has selected, re-resolved against every snapshot the store applies. /// /// `private(set)` with mutators below because the selection is the one set with a *grammar* /// coming (extend, range, successor-on-delete — 04-interactions.md ▸ Grammar), and every one of /// those rules will want a single funnel. Its siblings are plain `var`s: a drag and a cut are /// set wholesale by the gesture that owns them. public private(set) var selection: ItemReferenceSet = .empty /// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag" /// means here rather than a separate flag. /// /// Reload-resolved like any set: partial vanishing drops the survivors, and when the last /// member goes the drag has emptied itself and cancels (04-interactions.md ▸ Drag and drop, /// "an emptied drag cancels itself"). Deciding what an emptied drag *does* is the drag /// controller's job (m5); losing the members is this rule's. public var dragMembers: ItemReferenceSet = .empty /// The ⌘X staging set: cut items dim in place until a paste moves them (04-interactions.md ▸ /// Clipboard, "Cut is Finder-style deferred"). /// /// The pasteboard and the staged folder snapshots are not here — they are app-wide, outlive this /// store, and are 04's own story. This is only the *in-board* half: which of this board's items /// are showing as cut. "Deletion voids per item" is the reload rule above, applied here for /// free. public var pendingCut: ItemReferenceSet = .empty // MARK: Derived state, stored as its inputs /// The live search field's text (04-interactions.md § Search). Empty means no search is active. /// /// **The result set is not stored** — see this type's doc comment, kind 2. The predicate runs /// against whatever snapshot is current, so the filter is never stale, and the selection is kept /// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe /// — the same rule a reload uses, which is why "hidden cards leave the selection" needs no code /// of its own. public var searchQuery: String = "" // MARK: The overlay /// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder` /// for what it is and `resolve(against:)` for what a reload does to it. /// /// `private(set)`: its four legal transitions are the lifecycle methods below, and an unborn /// card is exactly the kind of state that rots if anyone may assign it. public private(set) var newCardPlaceholder: NewCardPlaceholder? // MARK: Per-open values /// Whether the trash quasi-lane is showing (03-board-ui.md ▸ Trash). /// /// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout /// choice, so it does not belong in the board registry beside window frames /// (02-architecture.md § Per-board app state). Nothing resets it — a fresh store means a fresh /// container means `false`. public var isTrashVisible: Bool = false public init() {} // MARK: - Selection /// Replaces the selection. /// /// Minimal on purpose — the selection's real grammar (extend, range, successor-on-delete) lands /// with the board UI. Deliberately **not** filtered against the snapshot: a caller selects what /// it is rendering, and `resolve(against:)` on the next reload is what keeps the set honest over /// time. public func select(_ ids: Set, liveness: Liveness) { selection = ItemReferenceSet(ids: ids, liveness: liveness) } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). public func clearSelection() { selection = .empty } // MARK: - The placeholder's lifecycle /// Opens the inline editor for a new card in `laneID`, replacing any placeholder already open. /// /// Replacing rather than refusing: two placeholders can never be open at once (one inline editor, /// one focus), so a second begin is the first one being abandoned — 04-interactions.md's /// click-away discard, arriving as a new creation instead of a click. public func beginPlaceholder(inLane laneID: ItemID) { newCardPlaceholder = NewCardPlaceholder(laneID: laneID) } /// Records what the user has typed. A no-op with no placeholder open — the draft has nowhere to /// live, and inventing a placeholder to hold it would put an editor on screen that nobody asked /// for. public func updateDraft(_ title: String) { newCardPlaceholder?.draftTitle = title } /// Marks the placeholder as waiting for the card the Writer just created. /// /// **The Writer call is not made here.** This type stores no URLs and performs no I/O; the create /// runs through `BoardStore.performWrite` at the UI's call site (m5), which is also the only /// place that can decide what a *failed* create should do with the editor. All this records is /// the id to watch for, so the overlay knows when its job is done. /// /// A no-op with no placeholder open: nothing is awaiting anything. public func commitPlaceholder(expecting id: ItemID) { newCardPlaceholder?.phase = .awaitingArrival(id) } /// Abandons the placeholder — Escape, an empty commit, a click-away (04-interactions.md ▸ /// Grammar). Disk was never touched, so there is nothing to undo. public func discardPlaceholder() { newCardPlaceholder = nil } // MARK: - Reload /// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot. /// /// Called by `BoardStore` on each *successful* reload and nowhere else — a failed reload leaves /// the snapshot alone, and state over a snapshot that did not change has nothing to re-resolve /// against. /// /// **Every item-referencing set is resolved independently.** They are re-grounded against the /// same snapshot but never against each other: a card leaving the selection must not disturb a /// drag in flight or a pending cut that also held it, and each set carries its own liveness /// side. Independence is what makes that a property of the code rather than of the order the /// lines happen to be in. /// /// **The placeholder has its own two rules**, because it references a lane rather than items: /// /// - **Discarded when its anchor lane is gone** — absent from the snapshot, or effectively /// tombstoned. A tombstoned lane renders nowhere (03-board-ui.md collapses it to a single /// trash entry), so its lane "vanished" in every sense 02-architecture.md means: "if the /// placeholder's lane vanished in the reload, it is discarded". /// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the /// snapshot. The real card arrived; the overlay's whole job was covering the gap between the /// Writer's create and the watcher's round trip, and holding it a moment longer would draw the /// card twice. The card is looked for anywhere in the snapshot rather than only under the /// anchor lane — it arrived, and where it landed is the snapshot's business. /// /// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the /// overlay, exactly as they do underneath the selection. /// /// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references /// an item, so no snapshot can invalidate either — the query's *results* change with every /// snapshot, which is precisely why the results are not stored here. public func resolve(against snapshot: BoardModel) { selection = selection.resolved(against: snapshot) dragMembers = dragMembers.resolved(against: snapshot) pendingCut = pendingCut.resolved(against: snapshot) newCardPlaceholder = resolvedPlaceholder(against: snapshot) } /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot. private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? { guard let placeholder = newCardPlaceholder else { return nil } guard let anchor = snapshot.lanes.first(where: { $0.id == placeholder.laneID }), !anchor.isDeleted else { return nil } if case let .awaitingArrival(id) = placeholder.phase, snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) { return nil } return placeholder } }