Lateral card navigation was pure geometry: the nearest drawn frame in the direction. That loses the walk in the card's own title — stepping from a 10-card lane's 8th card into a 3-card lane clamps to its 3rd, and coming back out, "the nearest frame at that height" is the 3rd card's height. The information the user was walking at stopped being on screen, so no rule over rectangles could have recovered it. So it is remembered instead. `TransientBoardState.lateralOrdinal` holds the 1-based position a run of ←/→ started from, counted over the cards the board is showing, and `NavigationMath.lateralHop` lands each hop on `min(ordinal, target lane's count)` of the next lane that is showing cards — collapsed and query-emptied lanes hopped over on `firstCard`'s rule rather than by the accident of registering no frames. 8th → 3rd → 8th. Every reset comes from one funnel and needs no enumeration anywhere: the ordinal is a defaulted `nil` parameter on `select`, so a click, a marquee, a ↑/↓ step, an ⌥-jump, a ⌫ successor, a lane-domain arrow and the reload's focus recovery all end the run by saying nothing. `resolve` adds the one rule a value referencing no item can need — the ordinal never outlives the head it was counted from — while a reload that leaves the cursor standing leaves the run standing too. A wide lane's interior masonry columns keep their spatial step and carry the ordinal through untouched: a column hop is not a lane hop, and stickiness is lane-granular over the logical order. With no lane in the direction the geometry has the last word, which is how → still reaches the shown trash. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
785 lines
47 KiB
Swift
785 lines
47 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
// MARK: - ItemReferenceSet
|
|
|
|
/// A set of UUIDs over the snapshot plus the container it lives in — **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 in this set's container, 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.
|
|
///
|
|
/// 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>
|
|
|
|
/// Which container the members live in — the board, or `.trash/` (`ItemContainer`).
|
|
///
|
|
/// A property of the set as a whole rather than of each member, because 04-interactions.md ▸ The
|
|
/// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards".
|
|
/// That is what makes re-resolution across a reload a matching rule rather than a partition.
|
|
public var container: ItemContainer
|
|
|
|
public init(ids: Set<ItemID> = [], container: ItemContainer = .board) {
|
|
self.ids = ids
|
|
self.container = container
|
|
}
|
|
|
|
/// Nothing referenced, on the board 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, **container 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 in one container
|
|
/// (`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 container survives even when the membership does not: an emptied set is still a set in a
|
|
/// container, 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), container: container)
|
|
}
|
|
|
|
/// This set re-grounded on `snapshot`: the members that are still there, **in the same
|
|
/// container**, 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 container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
|
|
/// foreign move that trashes a selected board card — or restores a selected trash card —
|
|
/// ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
|
|
/// invariant true across reloads so menu validation never sees a mixed selection. Drag
|
|
/// membership inherits it for free (04 ▸ Drag and drop's emptied-drag rule).
|
|
///
|
|
/// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
|
|
/// compute — "the old effective-liveness ancestor walk is retired with the tombstone model"
|
|
/// (02-architecture.md) — because a deleted card's folder has actually moved, and a folder is
|
|
/// either in the container or it is not.
|
|
public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
|
|
guard !ids.isEmpty else { return self }
|
|
return constrained(to: container.ids(in: snapshot))
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
///
|
|
/// **The filter never touches it** (04-interactions.md § Search, settled): "an open inline rename
|
|
/// survives the filter hiding its card — the editor is a surface the filter doesn't reach; it stays
|
|
/// open and focused, commits by UUID wherever the card lives, Escape abandons … and the
|
|
/// vanish-discard rule stays reserved for true liveness flips". Two halves, in two places: this
|
|
/// editor outlives a reload that stops its card matching, because `resolve(against:)` discards only
|
|
/// on a *vanish* and `constrainToSearch(in:)` touches the selection and nothing else; and its
|
|
/// *field* outlives it too, because `LaneView.rendered` keeps the renaming card's slot in the
|
|
/// masonry for as long as the editor is open.
|
|
///
|
|
/// 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?
|
|
|
|
/// **What position in its lane a run of ←/→ is holding on to** — the sticky ordinal (ruled
|
|
/// 2026-08-09), 1-based over the cards the board is *showing*, `nil` when no lateral run is in
|
|
/// flight.
|
|
///
|
|
/// The third memory of a gesture, and the one that exists because a *screen* cannot remember it:
|
|
/// stepping from a 10-card lane's 8th card into a 3-card lane clamps to its 3rd, and every rule
|
|
/// that reads drawn rectangles — `NavigationMath.nearest` included — then has only "3rd card"
|
|
/// to step back out with. The number the user is walking at survives here instead, unclamped, so
|
|
/// the hop back lands on the 8th again (`NavigationMath.lateralHop`).
|
|
///
|
|
/// **`nil` is a reset, and every existing caller gets one for free**: `select` takes it as a
|
|
/// defaulted parameter and stores it verbatim, so a click, a marquee, a ↑/↓ step, an ⌥-jump, a
|
|
/// ⌫ successor, a lane-domain arrow and the reload's focus recovery all end the run without any
|
|
/// of them naming it. Only the lateral hop passes a value. Reading `nil` on the next hop is not a
|
|
/// missing answer either — it means "start a run here", which captures the cursor's *actual*
|
|
/// position, which is the ruling's "resets to the actual new position" arrived at lazily.
|
|
///
|
|
/// **An ordinal rather than a card**, deliberately: the thing being preserved is a position in a
|
|
/// list, and the lists on either side of a hop are different lanes with different lengths. It
|
|
/// therefore references no item, which is why `resolve(against:)` has only one thing to say about
|
|
/// it — see there.
|
|
public private(set) var lateralOrdinal: Int?
|
|
|
|
/// 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 trash selection, which
|
|
/// never anchors creation) 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 column 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 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.
|
|
///
|
|
/// **The default is opt-out, because one caller genuinely names no gesture.** A Select All over a
|
|
/// one-card board is still a selection the user pointed at, but a rubber band is not: it names no
|
|
/// click to range from and no item to arrow from *whatever* it happens to enclose, and passing
|
|
/// `nil` cannot say so — `nil` is what asks for the default. So `defaultsSoleMember: false` takes
|
|
/// `anchor` and `head` verbatim, `nil` included, and a band that sweeps exactly one card leaves
|
|
/// both cursors empty instead of quietly acquiring them (`MarqueeControl.gesture`; a
|
|
/// `selectionHead` set mid-band would additionally fire `LaneView.cardStack`'s scroll-to under the
|
|
/// user's own drag).
|
|
///
|
|
/// 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.
|
|
///
|
|
/// - Parameter defaultsSoleMember: whether a `nil` anchor or head falls back to a sole member.
|
|
/// - Parameter lateralOrdinal: the sticky ordinal a run of ←/→ is carrying, or `nil` — which is
|
|
/// what **every** caller but the lateral hop passes, and is why "a non-lateral selection change
|
|
/// resets it" needs no enumeration of the gestures anywhere. See `lateralOrdinal`.
|
|
public func select(
|
|
_ ids: Set<ItemID>,
|
|
in container: ItemContainer,
|
|
anchor: ItemID? = nil,
|
|
head: ItemID? = nil,
|
|
defaultsSoleMember: Bool = true,
|
|
lateralOrdinal: Int? = nil
|
|
) {
|
|
selection = ItemReferenceSet(ids: ids, container: container)
|
|
let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil
|
|
selectionAnchor = anchor ?? sole
|
|
selectionHead = head ?? sole
|
|
self.lateralOrdinal = lateralOrdinal
|
|
}
|
|
|
|
/// 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. The sticky ordinal goes for
|
|
/// the same reason: there is no run left to be in the middle of.
|
|
public func clearSelection() {
|
|
selection = .empty
|
|
selectionAnchor = nil
|
|
selectionHead = nil
|
|
lateralOrdinal = 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: - Creation's carve-out
|
|
|
|
/// **A user-initiated creation clears the query** — 04-interactions.md § Search's one exception
|
|
/// to the pure predicate, and the single place it is stated.
|
|
///
|
|
/// > creating a card clears the search — creation's carve-out exists because a brand-new card
|
|
/// > must not be born invisible, and it is **stated by mechanism, not by gesture** (settled):
|
|
/// > *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, the
|
|
/// > header button, empty-space double-click, paste, and Finder file drops alike — while
|
|
/// > foreign/agent-filed cards keep riding the live filter.
|
|
///
|
|
/// So this is a *seam*, not a gesture's line: every path that mints an item because the user
|
|
/// asked for one calls it, and there are exactly three of them —
|
|
///
|
|
/// - `beginPlaceholder(inLane:after:)` below, which is itself the funnel for the four inline
|
|
/// creation gestures (⌘N, Return on a lane, the header button, a double-click on empty space);
|
|
/// - `ClipboardStore.perform` — ⌘V, cards and lanes alike, at the moment the items actually
|
|
/// land (a paste the pasteboard went stale under lands nothing and so clears nothing);
|
|
/// - `BoardStore.createCards(fromFiles:inLane:at:)` — the Finder file drop's *create* half. Its
|
|
/// attach half (`importAttachments`) deliberately does not call this: dropping files on a card
|
|
/// creates nothing, so there is no card to be born invisible.
|
|
///
|
|
/// **What is deliberately not here** is the other half of the same sentence: an item this board
|
|
/// receives without the user asking *it* for one keeps riding the filter. A cross-board drag
|
|
/// arrival is the near miss — it is a transfer whose destination-side clear 04 does not state,
|
|
/// and the enumerated mechanisms above are the ones it does — and an agent filing a card is the
|
|
/// far one (02-architecture.md's derived-result rule). **New Lane is not here either**, for the
|
|
/// carve-out's own reason rather than in spite of it: "lanes are never filtered out", so a lane
|
|
/// cannot be born invisible and has nothing to be rescued from — ⇧⌘N under a query adds a lane
|
|
/// showing a `0` badge, which is the filter behaving exactly as designed.
|
|
///
|
|
/// Widening the universe invalidates nothing, so unlike a *narrowing* write this needs no
|
|
/// snapshot and no `constrainToSearch(in:)` behind it — which is why the rule can live on this
|
|
/// type at all rather than on the store (see `searchQuery`).
|
|
public func noteUserCreation() {
|
|
searchQuery = ""
|
|
}
|
|
|
|
// 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 for the inline gestures**
|
|
/// (04-interactions.md § Search): "a brand-new card must not be born invisible". ⌘N, Return on a
|
|
/// lane, the lane header's button and a double-click on empty space all arrive here, so the four
|
|
/// of them state the carve-out once — by calling the rule's one home, `noteUserCreation()`,
|
|
/// which the paste and Finder-file-drop paths call too.
|
|
///
|
|
/// **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) {
|
|
noteUserCreation()
|
|
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 container.
|
|
/// 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. "If the
|
|
/// placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
|
|
/// delete is physical now, so gone is the only way a lane goes.
|
|
/// - **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 trashed, deleted, or gone discards the editor and its keystrokes silently —
|
|
/// "entering the trash is a vanish from the board; nothing is ever written into a vanished
|
|
/// folder". A foreign *move between lanes* is deliberately not a vanish — the editor follows
|
|
/// the UUID and the commit writes wherever the card now lives — which falls out for free from
|
|
/// matching on identity within the board container.
|
|
///
|
|
/// **`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**, in the *selection's* container: a range origin that
|
|
/// vanished or crossed containers 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 crosses containers 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.
|
|
///
|
|
/// - Parameter commentMatches: the transient comment index' current answer
|
|
/// (`CommentSearchIndex.matchingCards`), threaded straight through to the constraint below.
|
|
/// Defaulted, because it is the store's fact rather than this container's: everything else here
|
|
/// is a rule about *this* state, and a caller with no index — every test of the resolution rules
|
|
/// — means "no comment matches", which is what an empty set says.
|
|
public func resolve(against snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
|
|
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 container, the
|
|
// last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
|
|
let board = ItemContainer.board.ids(in: snapshot)
|
|
if let editor = renameEditor, !board.contains(editor.targetID) {
|
|
renameEditor = nil
|
|
}
|
|
if let lane = lastActiveLaneID, !board.contains(lane) {
|
|
lastActiveLaneID = nil
|
|
}
|
|
if selectionAnchor != nil || selectionHead != nil {
|
|
// The selection's container, because that is where both cursors live by construction —
|
|
// every route that sets either sets the selection to the same container in the same
|
|
// call. A vanished or container-crossed cursor is gone, which is the rule every item
|
|
// reference here gets. 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.container == .board ? board : selection.container.ids(in: snapshot)
|
|
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
|
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
|
}
|
|
// **The sticky ordinal never outlives the head it was counted from** — the one thing a reload
|
|
// has to say about a value that references no item. A run of ←/→ whose cursor vanished has
|
|
// nothing left to be a run *of*, and the next arrow re-derives its head from the selection's
|
|
// last member, which is a position the run never named. A reload that leaves the head standing
|
|
// deliberately leaves the run standing too: an agent filing a card mid-walk is not the user
|
|
// changing their mind.
|
|
if selectionHead == nil { lateralOrdinal = nil }
|
|
|
|
constrainToSearch(in: snapshot, commentMatches: commentMatches)
|
|
}
|
|
|
|
/// **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, the pending cut and the rename editor 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. The
|
|
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
|
|
/// hiding its card … the vanish-discard rule stays reserved for true liveness flips"
|
|
/// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign
|
|
/// edit that stops the renaming card matching drops it from the selection here and leaves the
|
|
/// keystrokes exactly where the user left them.
|
|
/// **A third occasion joined the two** with the comment index (04 ▸ Search, re-ruled 2026-07-29):
|
|
/// a landed sweep can *widen* the visible universe (a card matching only through its comments
|
|
/// appears) and, on a re-sweep, narrow it again — so `CommentSearchIndex.onRefine` calls this too.
|
|
/// Widening constrains nothing, which is why the seam is worth having anyway: the narrowing half is
|
|
/// the one that would otherwise leave a selection on a card nobody can see.
|
|
public func constrainToSearch(in snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
|
|
let filter = SearchFilter(query: searchQuery, commentMatches: commentMatches)
|
|
guard filter.isActive else { return }
|
|
|
|
let universe = filter.visibleIDs(in: snapshot, container: selection.container)
|
|
selection = selection.constrained(to: universe)
|
|
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
|
|
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
|
|
// A hidden cursor ends the lateral run, for `resolve`'s reason: the ordinal counts what the
|
|
// user can see, and a run stepping from a card the filter took away counts from nowhere.
|
|
if selectionHead == nil { lateralOrdinal = 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 snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }
|
|
|
|
if case let .awaitingArrival(id) = placeholder.phase,
|
|
snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
|
|
return nil
|
|
}
|
|
|
|
return placeholder
|
|
}
|
|
}
|