A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
128 lines
8.0 KiB
Swift
128 lines
8.0 KiB
Swift
/// 04-interactions.md's **⌘N target rule** (settled), as a pure function of the three things it
|
|
/// reads — the selection, the last-active lane, and the snapshot (`NewCardTargetTests`).
|
|
///
|
|
/// The rule verbatim, and each clause's branch below:
|
|
///
|
|
/// > with a card selected, the new card is created in that card's lane, immediately after it
|
|
/// > (paste-anchor consistency); with a lane selected, appended at its bottom (Return consistency);
|
|
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
|
|
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
|
|
/// > selected card, or appends to the last selected lane; … with nothing selected — or a
|
|
/// > **trash** selection, which never anchors creation — the **last-active lane** — the lane
|
|
/// > that most recently held selection or a creation in this window session — falling back to the
|
|
/// > first lane. … **Zero-lane board**: card creation … disable[s] via menu validation until a
|
|
/// > lane exists.
|
|
///
|
|
/// **A pure function rather than a method on the store** for the reason every rule in this codebase
|
|
/// that can be one is: the branches are lines of test rather than UI states to drive, and the menu
|
|
/// item's `disabled` and its action then read the *same* answer instead of two hand-kept-in-sync
|
|
/// conditions.
|
|
///
|
|
/// ### What it deliberately does not decide
|
|
///
|
|
/// - **The lane header's new-card button overrides this rule entirely** (11-command-nexus.md ▸
|
|
/// Pointer grammar, settled): "the click names its target lane, selection notwithstanding". That
|
|
/// call site passes its own lane and never comes here.
|
|
/// - **Return on a selected lane** is the same target as this rule's lane branch, but it is reached
|
|
/// by grammar rather than by the menu; it also passes its lane directly.
|
|
enum NewCardTarget {
|
|
|
|
/// Where a new card goes: which lane, and which card it lands immediately after (`nil` = the
|
|
/// lane's bottom). Exactly `NewCardPlaceholder`'s two anchoring fields, because that is what
|
|
/// this resolves *into*.
|
|
struct Resolution: Equatable {
|
|
let laneID: ItemID
|
|
let anchorCardID: ItemID?
|
|
}
|
|
|
|
/// The target, or `nil` when there is none — **the zero-lane board**, where "New Card,
|
|
/// Return-creation, and Paste with a card payload disable via menu validation until a lane
|
|
/// exists". `nil` is therefore the menu item's `disabled` condition as well as its refusal, so
|
|
/// the two can never disagree.
|
|
///
|
|
/// - Parameters:
|
|
/// - selection: the board's current selection, container included. A `.trash` selection
|
|
/// "never anchors creation" and is treated exactly as an empty one — the settled precedent
|
|
/// 04 ▸ Clipboard cites for paste, applied here to its source rule ("a trashed card's live
|
|
/// disk-lane never leaks in as 'the selected card's lane'").
|
|
/// - lastActiveLaneID: `TransientBoardState.lastActiveLaneID`, already cleared by the reload
|
|
/// rule if its lane vanished — but re-checked here anyway, because a caller need not have
|
|
/// reloaded since the lane went.
|
|
static func resolve(
|
|
selection: ItemReferenceSet,
|
|
lastActiveLaneID: ItemID?,
|
|
snapshot: BoardModel
|
|
) -> Resolution? {
|
|
// **Collapsed lanes are not creation targets** (03-board-ui.md § Lane ▸ Collapsed lanes:
|
|
// "⌘N new-card targeting skips collapsed lanes"): the placeholder is a pseudo-card drawn in the
|
|
// lane's masonry (`LaneSlot.placeholder`), and a folded lane draws none — so a card created
|
|
// there would be a focused text field rendered nowhere, with no way to type a title into it and
|
|
// no way out but Escape. Skipping is therefore not a preference but the only coherent answer,
|
|
// and it is applied to every branch below by narrowing the lane list once.
|
|
//
|
|
// **A board whose every lane is folded has no target at all**, which is exactly the zero-lane
|
|
// board's answer and reaches the same place: `nil` is New Card's `disabled` condition as well
|
|
// as its refusal, so the item greys out rather than doing nothing when pressed.
|
|
let lanes = snapshot.lanes.filter { !LaneLayoutMath.isCollapsed($0) }
|
|
guard !lanes.isEmpty else { return nil }
|
|
|
|
// **A selection inside a folded lane falls through rather than refusing** — the stale
|
|
// selection's rule verbatim, for its reason: the user pressed ⌘N and the board has lanes it
|
|
// can create into. So collapsing a lane with one of its cards selected leaves ⌘N working, at
|
|
// the last-active lane below. `flattenAnchor` itself is deliberately untouched: it is
|
|
// **shared with paste** ("the same anchor serves paste"), and a paste into a folded lane is
|
|
// perfectly coherent — it writes a rank, it renders nothing, and the card is there when the
|
|
// lane unfolds.
|
|
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot),
|
|
lanes.contains(where: { $0.id == anchor.laneID }) {
|
|
return anchor
|
|
}
|
|
// Nothing selected, a trash selection, or a stale one — the ids name nothing the board
|
|
// renders, a selection the next reload will drop. Falls through rather than refusing: the
|
|
// user pressed ⌘N and the board has lanes. The target is then the lane that most recently
|
|
// held selection or a creation, and the first lane when there is no such lane (or it has
|
|
// since gone).
|
|
if let lastActiveLaneID, let lane = lanes.first(where: { $0.id == lastActiveLaneID }) {
|
|
return Resolution(laneID: lane.id, anchorCardID: nil)
|
|
}
|
|
return lanes.first.map { Resolution(laneID: $0.id, anchorCardID: nil) }
|
|
}
|
|
|
|
/// **The shared anchor, on its own** — "a multi-selection anchors at its last member in flatten
|
|
/// order (lane `order`, then card `order`, the multi-drag order; the same anchor serves paste)".
|
|
///
|
|
/// Extracted rather than left inside `resolve` because paste needs *exactly this clause* and not
|
|
/// the two that surround it. Card paste is `resolve` verbatim (the last-active-lane fallback and
|
|
/// all), but **lane paste has a different fallback** — "nothing selected = the board's right end",
|
|
/// never the last-active lane — so it takes the anchor and stops. Two derivations of "the last
|
|
/// member in flatten order" would be two chances for creation and paste to disagree about the one
|
|
/// rule 04 says they share.
|
|
///
|
|
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
|
|
/// an empty selection, a **trash** one ("a trash selection never anchors paste",
|
|
/// settled — and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
|
|
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
|
/// nothing the board renders.
|
|
static func flattenAnchor(selection: ItemReferenceSet, snapshot: BoardModel) -> Resolution? {
|
|
guard selection.container == .board, !selection.ids.isEmpty else { return nil }
|
|
|
|
// The snapshot's lanes and cards are already in display order, so the flatten order is one
|
|
// walk, and the *last* hit is the anchor. Selection is homogeneous (cards XOR lanes), so only
|
|
// one of the two branches ever fires within a walk; a sole selection is simply the degenerate
|
|
// one-member case of the same rule.
|
|
var anchor: Resolution?
|
|
for lane in snapshot.lanes {
|
|
// A selected lane: creation appends at its bottom, Return consistency; paste lands after
|
|
// the lane itself.
|
|
if selection.ids.contains(lane.id) {
|
|
anchor = Resolution(laneID: lane.id, anchorCardID: nil)
|
|
}
|
|
// A selected card: its lane, immediately after it — paste-anchor consistency.
|
|
for card in lane.cards where selection.ids.contains(card.id) {
|
|
anchor = Resolution(laneID: lane.id, anchorCardID: card.id)
|
|
}
|
|
}
|
|
return anchor
|
|
}
|
|
}
|