The board's live title+body filter per 04-interactions.md § Search: - SearchFilter — a pure value folding the query once (case- and diacritic-insensitive substring, locale-stable); title OR body matches, attachment filenames never searched; only the literal empty string is inactive. - One universe: the filter threads through SelectionGrammar's order lists as a defaulted parameter, so ranges, Select All, arrow navigation, the marquee, drop zones, count badges, and the shown trash all read the same filtered set by construction; lanes are deliberately never filtered out (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the selection through the existing constrain primitive, run on every query change and as the last line of the reload resolve; the delete successor is filtered so ⌫ never selects a hidden neighbour. - The field: an NSSearchField-backed toolbar item (the toolbar's sole default item); Edit ▸ Find ⌘F focuses it through a focused-value presentation; stock field-editor dispatch — Return swallowed, Tab is the keep-filter path to the board, board commands stay enabled except the caret-chord pair, now one shared caretChordsYield expression. - Escape is staged: clear the non-empty query (focus stays), hand an empty field back to the board, clear an active search from board focus — before Escape's clear-selection meaning. - Creating a card clears the search (the placeholder funnel); a rename deliberately gets no carve-out; filter reflow rides the content spring keyed narrowly on the query. 903 unit tests (24 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
712 lines
41 KiB
Swift
712 lines
41 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: - 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): 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<ItemID> {
|
|
var universe: Set<ItemID> = []
|
|
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<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
|
|
}
|
|
}
|