02 rules the trashed side has exactly one definition: the set with trash rows — a card carrying its own deleted: under a tombstoned lane is in neither universe, so an anchor or selection can never survive on an item that renders nowhere. The membership rule now lives once, in Liveness.walk, and ItemReferenceSet.idUniverse, TrashModel.entries, paths, and emptyTrashTargets all derive from it — the old lane-OR-card logic that admitted subsumed cards to the trashed side is gone, and the two universes deliberately no longer partition the board. Put Back, Delete Immediately, and Empty Trash outcomes are unchanged: a tombstoned lane still moves and purges whole, its nested tombstones with it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
755 lines
43 KiB
Swift
755 lines
43 KiB
Swift
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: - The one definition of a side
|
|
|
|
extension Liveness {
|
|
|
|
/// Every item `snapshot` has on this side, visited in **board order** — lanes left to right,
|
|
/// each lane immediately before its own cards — as the lane it lives under and, for a card, the
|
|
/// card itself.
|
|
///
|
|
/// **This is the definition, and it is the only one** (02-architecture.md § Changes from Kanban,
|
|
/// settled: "universe and rows are one function, never a broader set with a pointer-side
|
|
/// subset"). Everything that asks what is on a side is this walk with a different accumulator:
|
|
/// `ItemReferenceSet.idUniverse` collects ids, `TrashModel.entries` builds the trash's rows,
|
|
/// `TrashModel.paths` and `emptyTrashTargets` build folders. Two spellings of the rule would be
|
|
/// two things to keep in step, and the one they would eventually disagree about is precisely the
|
|
/// item below.
|
|
///
|
|
/// **The whole rule is the `continue`: a tombstoned lane subsumes its subtree.** It contributes
|
|
/// one item to the trashed side — its own row — and its cards contribute nothing to *either*
|
|
/// side, whatever their own flags say. That is 03-board-ui.md § Trash's absolute ancestor walk
|
|
/// stated as code: "a card that carries its own `deleted:` under a tombstoned lane has **no row
|
|
/// of its own**".
|
|
///
|
|
/// **So the two sides do not partition the board, and that is the point.** A card beneath a
|
|
/// tombstoned lane is in *neither* universe, because it renders nowhere — no row, no membership.
|
|
/// A selection, a drag, a pending cut, a range anchor or a navigation head can therefore never
|
|
/// survive a reload sitting on something no surface would draw, and 04-interactions.md § Search's
|
|
/// hidden-cards-leave-the-selection rule and 02's reload-survival rule stay one rule rather than
|
|
/// two that happen to agree.
|
|
///
|
|
/// Non-escaping and accumulator-driven rather than array-returning: the callers below run on
|
|
/// every reload and on every menu validation, and none of them wants a board-sized copy of the
|
|
/// model to throw away.
|
|
func walk(_ snapshot: BoardModel, visiting visit: (Lane, Card?) -> Void) {
|
|
for lane in snapshot.lanes {
|
|
if lane.isDeleted {
|
|
// The subsumption, both halves of it: the lane is a trash row, and its cards are
|
|
// nobody's — so the loop below never runs for them.
|
|
if self == .trashed { visit(lane, nil) }
|
|
continue
|
|
}
|
|
if self == .live { visit(lane, nil) }
|
|
for card in lane.cards where Liveness(isDeleted: card.isDeleted) == self {
|
|
visit(lane, card)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<ItemID>
|
|
public var liveness: Liveness
|
|
|
|
public init(ids: Set<ItemID> = [], 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<ItemID>) -> 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), and the trashed
|
|
/// side is exactly the trash's rows: `Liveness.walk` is the one definition both sides read.
|
|
/// Tombstoning a lane therefore ejects its cards from a live set even though their own flags
|
|
/// never changed — and does **not** hand them to a trashed set, because the lane's single entry
|
|
/// subsumes them (03-board-ui.md). A card under a tombstoned lane renders nowhere on either
|
|
/// side, 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` on `side` — the reload direction's universe.
|
|
///
|
|
/// One line over `Liveness.walk`, which is where the rule itself lives and is stated: the live
|
|
/// side is the live lanes and their unflagged cards, and the trashed side is *exactly* the trash's
|
|
/// rows — tombstoned lanes, plus cards carrying their own `deleted:` under a live lane. Nothing
|
|
/// else is in either, so a card hidden beneath a tombstoned lane belongs to no universe and no
|
|
/// set may go on referencing it.
|
|
static func idUniverse(of snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
|
|
var universe: Set<ItemID> = []
|
|
side.walk(snapshot) { lane, card in universe.insert(card?.id ?? lane.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<ItemID>, 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
|
|
}
|
|
}
|