Files
lanework/Kanban/UI/Board/NavigationMath.swift
T
rzen bab456c08d Collapsible lanes — frontmatter-backed slim strips outside the width division
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
2026-08-08 22:53:10 -04:00

188 lines
9.4 KiB
Swift

import CoreGraphics
// MARK: - Spatial navigation
/// The arrows' geometry — "nearest card in the direction, across interior grid columns and lanes"
/// (04-interactions.md ▸ Grammar), as a pure function of the drawn frames (`NavigationMathTests`).
///
/// **It reads the marquee's registry, deliberately.** The frames come from `MarqueeTargetRegistry`,
/// which the views populate with what they actually drew — so the keyboard and the rubber band
/// answer "where is that card" from one set of rectangles, and geometry can never disagree with
/// hit-testing. A second derivation off the masonry's arithmetic would be a second answer, and one
/// that a lane resize, a reorder in flight or a foreign reload could falsify.
///
/// **It is also how the hidden trash stays invisible for free**: a hidden column registers nothing,
/// so there is nothing here to filter out — 04's "hidden, it is invisible to every gesture" needs no
/// code of its own. **A collapsed lane's cards ride the same mechanism** (03-board-ui.md § Lane ▸
/// Collapsed lanes: "cards inside are not rendered"): a lane drawn as a slim strip lays out no card
/// faces, so it registers no frames and `nearest` cannot step into one. The `⌥`-jumps are the half
/// that *does* need code, because they name an absolute destination rather than a neighbour — see
/// `firstCard(scanning:filter:)`.
///
/// Pure and `CoreGraphics`-only, for `SelectionGrammar`'s reason: the branches become lines of test
/// rather than gestures to drive, and the four arrow handlers stay thin over it.
public enum NavigationMath {
public enum Direction: Sendable, Equatable {
case up
case down
case left
case right
}
/// The nearest target in `direction` from `origin`, or `nil` when the direction has no candidate.
///
/// The rule, in three parts:
///
/// - **Strictly beyond, along the primary axis.** A candidate's centre must sit at least 1pt
/// past the origin's centre in the direction travelled. The tolerance is what excludes the
/// origin itself and what keeps a card sharing a row (or a column) with the origin from
/// counting as "above" it because of a sub-pixel layout difference.
/// - **Orthogonal drift costs double.** The score is the primary-axis centre distance plus twice
/// the orthogonal one, so a card straight ahead beats a nearer one off to the side — which is
/// what makes ↓ walk down a masonry column rather than wandering across it, and ← / → cross to
/// the neighbouring lane at the same height.
/// - **Ties are broken by position, then identity** (`MarqueeMath.isAbove`), so identical input
/// picks identically twice.
///
/// - Parameter predicate: which targets are eligible — the ⇧-arrow's same-side restriction, and
/// nothing else so far. A plain arrow passes everything, because "plain arrows still walk
/// across" the live/trash boundary (04 ▸ The trash).
public static func nearest(
from origin: CGRect,
direction: Direction,
among targets: [MarqueeTarget],
where predicate: (MarqueeTarget) -> Bool = { _ in true }
) -> ItemID? {
/// Below this, a candidate is level with the origin rather than beyond it.
let threshold: CGFloat = 1
var best: MarqueeTarget?
var bestScore = CGFloat.infinity
for candidate in targets where predicate(candidate) {
let primary: CGFloat
let orthogonal: CGFloat
switch direction {
case .up:
primary = origin.midY - candidate.frame.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .down:
primary = candidate.frame.midY - origin.midY
orthogonal = abs(candidate.frame.midX - origin.midX)
case .left:
primary = origin.midX - candidate.frame.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
case .right:
primary = candidate.frame.midX - origin.midX
orthogonal = abs(candidate.frame.midY - origin.midY)
}
guard primary >= threshold else { continue }
let score = primary + 2 * orthogonal
if score < bestScore {
best = candidate
bestScore = score
} else if score == bestScore, let current = best, MarqueeMath.isAbove(candidate, current) {
best = candidate
}
}
return best?.id
}
/// **The first card the board is actually showing**, scanning `lanes` in the order given — the
/// landing every absolute keyboard destination shares: ⌥←/⌥→'s first/last lane, and the seed an
/// arrow from an empty selection takes (04-interactions.md ▸ Grammar).
///
/// Three ways a lane is scanned past, and they are one rule — *the user cannot see into it*:
///
/// - it holds no cards;
/// - the search query hid all of them (04 § Search — "a jump that landed nowhere because the end
/// lane happens to be empty would be a dead key", and a lane the query emptied is empty to the
/// eye);
/// - it is **collapsed** (03-board-ui.md § Lane ▸ Collapsed lanes): the slim strip draws no card
/// faces at all, so a jump that landed on one would select something rendered nowhere and leave
/// the arrows with no frame to step from. The strip itself stays selectable **as a lane**, which
/// is the lane domain's business and not this scan's.
///
/// Pure, and here rather than in the view for `SortMath`'s reason: the three skips are lines of
/// test instead of a board to drive.
public static func firstCard(
scanning lanes: some Sequence<Lane>,
filter: SearchFilter = .inactive
) -> ItemID? {
for lane in lanes where !LaneLayoutMath.isCollapsed(lane) {
if let card = lane.cards.first(where: { filter.matches($0) }) { return card.id }
}
return nil
}
}
// MARK: - Within-lane sort
/// ⌥⌘↑/⌥⌘↓'s arithmetic — "the selected card(s) move one position within the lane — logical
/// `order`, across interior masonry columns" (04-interactions.md ▸ The map), as a pure permutation
/// of the lane's rendered card ids (`NavigationMathTests`).
///
/// **Logical order, never geometry.** The masonry's columns are a rendering; the thing being moved
/// is the `order` ladder, which is also 10-accessibility.md's logical-order rule. So this function
/// never sees a frame — it is given the lane's ids top-to-bottom and hands back the same ids in a
/// new order, and `BoardStore.sortSelection` turns that into the minimum set of `order` rewrites.
public enum SortMath {
public enum Direction: Sendable, Equatable {
case up
case down
}
/// The lane's ids after one press, or `nil` for a no-op.
///
/// Two behaviours, and which one fires depends only on whether the selection is already
/// contiguous:
///
/// - **Non-contiguous gathers, and only gathers.** "A non-contiguous multi-selection gathers on
/// the first press: the cards collect into a contiguous block anchored at the first selected
/// card (first = lowest logical order; the rest follow in preserved relative order), and
/// subsequent presses move the block one position." The gather is therefore direction-blind —
/// the press that gathers does not also step, which is what makes the second press's meaning
/// unambiguous.
/// - **Contiguous steps one position**, hopping the single unselected sibling above (or below)
/// the block, so the block travels as a unit. At the ladder's end there is nothing to hop, and
/// the answer is `nil`.
///
/// `nil` rather than "the input unchanged" so the menu item's `disabled` state and the store's
/// write path read the *same* answer — `LaneWidthCommands`' rule, and for its reason.
///
/// Ids in `selected` that are not in `ordered` are ignored: a selection the next reload will
/// drop must not decide what a press does now.
public static func reordered(
_ ordered: [ItemID],
moving selected: Set<ItemID>,
_ direction: Direction
) -> [ItemID]? {
let doomed = ordered.indices.filter { selected.contains(ordered[$0]) }
guard let first = doomed.first, let last = doomed.last else { return nil }
let block = doomed.map { ordered[$0] }
// Contiguity is a property of the positions, not of the count: N members spanning exactly N
// slots is the block that steps; anything wider gathers first.
guard doomed.count == last - first + 1 else {
var others = ordered.filter { !selected.contains($0) }
// Everything before the first selected card is unselected by definition, so the block's
// landing index among the survivors *is* that first index — "anchored at the first
// selected card".
others.insert(contentsOf: block, at: first)
return others
}
switch direction {
case .up:
guard first > 0 else { return nil }
return Array(ordered[..<(first - 1)]) + block + [ordered[first - 1]] + Array(ordered[(last + 1)...])
case .down:
guard last + 1 < ordered.count else { return nil }
return Array(ordered[..<first]) + [ordered[last + 1]] + block + Array(ordered[(last + 2)...])
}
}
}