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. /// /// **`String`-backed and `Codable` because the clipboard manifest carries one** (04-interactions.md /// ▸ Clipboard: a manifest records its entries' "source side (live/trashed)"). The raw spellings are /// therefore pasteboard API — a manifest written before a quit is decoded after the relaunch — and /// they are the case names so nothing has to remember a second vocabulary. public enum Liveness: String, Codable, 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 /// The card the new one is being born **immediately after**, or `nil` for the lane's bottom. /// /// It exists because 04-interactions.md's ⌘N target rule is a *position*, not just a lane: /// "with a card selected, the new card is created in that card's lane, immediately after it /// (paste-anchor consistency)". Every other entry point — Return on a lane, the header button, /// a double-click on empty space, ⌘N with nothing selected — appends at the bottom and passes /// `nil`. /// /// **A position, deliberately not a rank.** The `order` is computed at *commit* time against /// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor /// opened: an agent filing a card into the gap mid-typing must move the new card, not be /// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the /// lane's bottom rather than failing — the lane is the anchor that matters, and the card being /// created is the user's, not the vanished neighbour's. public var anchorCardID: 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, anchorCardID: ItemID? = nil, draftTitle: String = "", phase: Phase = .editing ) { self.laneID = laneID self.anchorCardID = anchorCardID self.draftTitle = draftTitle self.phase = phase } } // MARK: - RenameEditor /// The **third inline editor** (04-interactions.md ▸ Grammar): a card's or a lane's title being /// edited in place, on the card face or in the lane header. /// /// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the /// contrast is worth stating once: /// /// | | `NewCardPlaceholder` | `RenameEditor` | /// |---|---|---| /// | References | a *lane* (no identity yet) | an **item**, by UUID | /// | Click-away | discards — nothing is on disk | **commits** — the item is real | /// | Empty commit | discards the whole draft | removes the `title` key | /// | A reload can | drop it, or hand it off | only drop it | /// /// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is /// invisible — the editor follows the UUID and the commit writes the title wherever the card now /// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed /// into another lane mid-typing is still renamed correctly. /// /// There is no phase enum. The placeholder needs one because it outlives its own commit (the /// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for — /// the item is already on screen, and the commit's round trip simply updates it. public struct RenameEditor: Sendable, Equatable { /// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the /// folder by finding the id in the snapshot, and every rule here — the vanish discard, the /// UUID tracking, the empty-commit removal — reads identically for both levels. public var targetID: ItemID /// What the user has typed. **Seeded from the current title** when the editor opens (an /// untitled item seeds empty, since "Untitled" is a rendering, not a value — /// 03-board-ui.md § Card face), and thereafter the only place the draft exists. public var draftTitle: String public init(targetID: ItemID, draftTitle: String = "") { self.targetID = targetID self.draftTitle = draftTitle } } // 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 inline editors** — `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; and `renameEditor`, anchored to an *item* and discarded when that item vanishes. /// Two editors, one at a time: they share the app's single keyboard focus, so beginning either /// ends the other. /// /// 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. /// `lastActiveLaneID` is per-open in the same sense — "in this window session" is exactly the /// scope of a container built with its store — but it *does* reference an item, so `resolve` has /// something to say about it. /// /// `@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 /// **Where a ⇧-click ranges from** — the item the last plain or ⌘ click named /// (04-interactions.md § Selection, "⇧-click range-extends"). /// /// A *memory of a gesture*, like `lastActiveLaneID` and for the same reason: it is not /// derivable from the selection. A range replaces the whole set and deliberately leaves the /// anchor put, so successive ⇧-clicks sweep out from one origin instead of walking it along — /// which means nothing in the set marks it. /// /// **A marquee and every wholesale selection pass no anchor deliberately.** There is no click /// behind them to range from, so a ⇧-click afterwards behaves as a plain click — the same /// degrade `SelectionGrammar` gives a vanished anchor, reached honestly rather than by inventing /// an origin the user never named. /// /// It lives on the selection's side by construction, so `resolve(against:)` re-grounds it with /// the same rule every other item reference gets. public private(set) var selectionAnchor: ItemID? /// **Where the next arrow steps from** — the navigation cursor, AppKit's "lead": the item the /// last click or arrow named (04-interactions.md ▸ Grammar's spatial navigation). /// /// **Distinct from the anchor, and the difference is the whole reason both exist.** A ⇧-gesture /// leaves the anchor exactly where it was — that is what makes successive extensions sweep out /// from one origin — while the *head* walks to whatever was just reached, because the next /// ⇧-arrow has to continue from there rather than from the origin. A plain click or arrow moves /// both; a ⇧-click or ⇧-arrow moves only this. /// /// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded /// by `resolve(against:)` under the same universe rule. public private(set) var selectionHead: ItemID? /// 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 (`constrainToSearch(in:)`). /// /// **Written through `BoardStore.searchQuery`, not here**, on every path that *narrows* it: the /// store is what has a snapshot, and narrowing without constraining would leave a selection /// pointing at cards nobody can see. Widening — `beginPlaceholder`'s creation clear, and the /// clear Escape performs — is safe from anywhere, because a bigger universe invalidates nothing. public var searchQuery: String = "" // MARK: The inline editors /// 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? /// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`. /// /// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write /// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared /// out from under a commit that was about to read its draft. public private(set) var renameEditor: RenameEditor? /// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling /// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here /// rather than in a view. /// /// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text /// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" — /// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway. /// /// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a /// session assignable from anywhere could be re-aimed behind the reload rule's back. public private(set) var styleEditor: StyleEditorSession? /// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one /// boolean: "while an inline title editor — rename or the new-card placeholder — is focused, /// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu /// validation". /// /// Every board-mutating menu item validates against this, so the rule is stated once rather /// than re-derived per item. The one carve-out the design names — Open Card ⌘↩, which stays /// enabled to commit the edit and open the window — is the item's business, not this flag's. public var isEditingInline: Bool { newCardPlaceholder != nil || renameEditor != nil } // MARK: Per-open values /// The lane that most recently held selection or a creation **in this window session** — /// 04-interactions.md's ⌘N target rule's fallback when nothing (or a tombstoned something) is /// selected, before the last resort of the first lane. /// /// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing /// in the snapshot that could reconstruct which lane the user was last working in, which is /// precisely why the rule exists — a ⌘N after an Escape should file the card where the user /// has been, not at the far left of the board. /// /// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere /// is no target at all; `NewCardTarget` then falls through to the first lane. public private(set) var lastActiveLaneID: ItemID? /// 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, and sets the anchor a subsequent ⇧-click ranges from and the head a /// subsequent arrow steps from. /// /// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click /// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its /// own is the **anchor default**, which the head shares: `nil` with a sole member takes that /// member, `nil` with any other count takes nothing. That makes the two callers that pass /// nothing behave exactly as they should — a one-item selection made by any route is a /// legitimate range origin *and* a legitimate place to arrow from, while a marquee or a Select /// All names no gesture and so leaves a ⇧-click acting plain and the arrows re-deriving a /// position from the set's last member. /// /// 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, anchor: ItemID? = nil, head: ItemID? = nil) { selection = ItemReferenceSet(ids: ids, liveness: liveness) let sole = ids.count == 1 ? ids.first : nil selectionAnchor = anchor ?? sole selectionHead = head ?? sole } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and /// the head go with it: an empty selection has no origin to range from and no cursor to step /// from — which is exactly the state the arrows' seed rule answers. public func clearSelection() { selection = .empty selectionAnchor = nil selectionHead = nil } /// Records that `laneID` is where the user is working — a lane selected, or created into. /// /// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the /// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule /// leans on exactly that (⌘N *with nothing selected* is the case the memory serves). The only /// thing that clears it is the lane going away, which `resolve(against:)` owns. public func noteActiveLane(_ laneID: ItemID?) { guard let laneID else { return } lastActiveLaneID = laneID } // MARK: - The placeholder's lifecycle /// Opens the inline editor for a new card in `laneID`, replacing any editor already open and /// marking the lane active. /// /// Replacing rather than refusing: two inline editors can never be open at once (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 (02-architecture.md § Layering /// states it outright: "Starting a new creation while a placeholder is open is a click-away /// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away /// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the /// focus is being taken by another editor before the user has said they are done. /// /// **Creation clears the search, and this is the funnel** (04-interactions.md § Search): "a /// brand-new card must not be born invisible". Every entry point to creation goes through here /// — ⌘N, Return on a lane, the lane header's button, a double-click on empty space — so the /// carve-out is stated once instead of four times. /// /// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate /// with one exception, not two". A rename committed under an active search re-runs the /// predicate like any other edit, and a title that stops matching animates its card out and /// drops it from the selection — which falls out of `BoardStore.commitRename`'s ordinary write /// and the reload's `constrainToSearch(in:)`, with nothing here to arrange it. /// /// - Parameter anchorCardID: the card the new one is born immediately after (04's ⌘N target /// rule), or `nil` for the lane's bottom — which is what Return, the header button, and a /// double-click on empty space all pass. public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) { searchQuery = "" renameEditor = nil newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID) noteActiveLane(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: - The rename editor's lifecycle /// Opens the inline rename of `targetID`, seeded with `currentTitle`. /// /// The seed is the caller's because this type holds no snapshot: the two entry points (Return /// on a sole selected card, Board ▸ Rename) both have the item in hand already. `nil` seeds an /// empty field — an untitled item has no title to edit, and "Untitled" is a rendering that /// must never be typed into the file (03-board-ui.md § Card face). /// /// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor. public func beginRename(of targetID: ItemID, currentTitle: String?) { newCardPlaceholder = nil renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "") } /// Records what the user has typed. A no-op with no editor open, like `updateDraft`. public func updateRenameDraft(_ title: String) { renameEditor?.draftTitle = title } /// Closes the rename editor without writing — **Escape's abandon**, and also how /// `BoardStore.commitRename` retires the editor once its write has been issued (or refused). /// /// There is no `commitRename` here for the same reason there is no create here: this type /// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path /// disk was never touched, and on the commit path disk has already been touched by the time /// this runs. public func discardRename() { renameEditor = nil } // MARK: - The style editor's lifecycle /// Opens the Style… popover on `target` — the menu item's call and both context menus' /// (03-board-ui.md § Styling ▸ Controls). /// /// Replacing any session already open, for the inline editors' reason turned inside out: there is /// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike /// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist /// with one (Style… disables while an editor is focused), so clearing them here would be a rule /// about a state that menu validation already rules out. public func beginStyleEditor(for target: StyleTarget) { styleEditor = StyleEditorSession(target: target) } /// Closes the popover — the user dismissing it, and the anchor's response to a session the /// reload rule emptied. public func discardStyleEditor() { styleEditor = 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. /// /// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸ /// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target /// that is tombstoned, deleted, or gone discards the editor and its keystrokes silently. /// A foreign *move* is deliberately not a vanish — the editor follows the UUID and the commit /// writes wherever the item now lives — which falls out for free from matching on identity /// rather than on position. Liveness is **effective**, so a card under a lane an agent just /// tombstoned vanishes with it. /// /// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md /// gives every item-referencing piece of transient state: nothing may reference an item the /// current universe does not have. It is not an `ItemReferenceSet` only because it is one /// optional rather than a set on a side — the rule it obeys is the same one. /// /// **`selectionAnchor` obeys it too**, on the *selection's* side: a range origin that vanished /// or flipped liveness is gone, and the next ⇧-click acts as a plain click rather than ranging /// from somewhere that renders nowhere. It deliberately does **not** have to stay *in* the /// selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor selected and /// a range from it is still exactly what the user asked for. /// /// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls, /// settled): a member that vanishes or flips liveness leaves the set — so the editor's /// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload /// clears the session, which is how "the popover dismisses when it empties" reaches the screen. /// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns /// both halves. /// /// **`searchQuery` is re-applied rather than re-resolved.** It references no item, so no /// snapshot can invalidate it — but its *results* change with every snapshot, and a reload /// landing under an active query can hide a selected card as surely as a query change can (an /// agent editing a title out of the match is the case). So `constrainToSearch(in:)` runs last, /// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one /// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with /// nothing to say here at all. 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) styleEditor = styleEditor?.resolved(against: snapshot) // One universe computed once and asked three questions — the rename target's liveness, the // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's. let live = ItemReferenceSet.idUniverse(of: snapshot, on: .live) if let editor = renameEditor, !live.contains(editor.targetID) { renameEditor = nil } if let lane = lastActiveLaneID, !live.contains(lane) { lastActiveLaneID = nil } if selectionAnchor != nil || selectionHead != nil { // The selection's side, because that is the side both cursors live on by construction — // every route that sets either sets the selection to the same side in the same call. A // vanished or liveness-flipped cursor is gone, which is the rule every item reference // here gets: "a flip is a vanish from its side of the boundary". The head then re-derives // from the selection's last member on the next arrow, which is the same fallback an // anchorless ⇧-arrow already uses. let universe = selection.liveness == .live ? live : ItemReferenceSet.idUniverse(of: snapshot, on: selection.liveness) if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } } constrainToSearch(in: snapshot) } /// **Hidden cards leave the selection** (04-interactions.md § Search) — the constraint rule with /// the *filter's* universe supplied, which is the second of the two directions /// `ItemReferenceSet.constrained(to:)`'s doc comment names. /// /// Called on exactly two occasions, and they are the two ways the visible universe can narrow: /// when the **query changes** (`BoardStore.searchQuery`'s setter) and when a **reload lands /// under an active query** (`resolve(against:)` above, whose last line this is). Both hand it /// the current snapshot, because the predicate has nothing else to run against. /// /// **A no-op with no search running**, deliberately: with the filter off the visible universe is /// the whole board, so constraining to it could only ever be the identity — and stating that as /// an early return rather than letting it fall out keeps the reload path free of a board-sized /// set computation nobody needs. /// /// The anchor and the head obey the same universe rule the reload gives them, for the same /// reason: a range origin or a navigation cursor sitting on a card the filter hid would range or /// step from somewhere the user cannot see. Neither has to stay *in* the selection — that /// asymmetry is `resolve`'s and survives here untouched. /// /// **The drag and the pending cut are deliberately left alone.** 04 hides cards and says one /// thing about the consequence — that they leave the *selection*. A cut is staged content /// waiting for a paste that may well happen after the search clears, and a drag under a live /// filter is a gesture in flight, not a set the filter has any claim on. public func constrainToSearch(in snapshot: BoardModel) { let filter = SearchFilter(query: searchQuery) guard filter.isActive else { return } let universe = filter.visibleIDs(in: snapshot, on: selection.liveness) selection = selection.constrained(to: universe) if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil } } /// 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 } }