diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 7bfad2f..a27a8f1 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -44,6 +44,11 @@ struct BoardWindowHost: View { /// menu-bar items, and a menu item cannot present anything of its own. @State private var trashConfirmations = TrashConfirmations() + /// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason, + /// and published the same way: a menu item has no window of its own, and only this view holds + /// the board half of a card window's `(board, card)` identity — see `CardOpener`. + @State private var cardOpener = CardOpener() + @State private var phase: Phase = .opening private enum Phase { @@ -78,17 +83,12 @@ struct BoardWindowHost: View { // attaches after this body first runs, and the lane-resize drag needs the *live* // window to grow at its right edge (03-board-ui.md § Lane). // - // `openCard` is the host's too, for a different reason: a card window's identity is - // `(board, card)` and only this view holds the board half. `openWindow(value:)` with - // a ref that already has a window focuses it, so "at most one card window per card - // (reopen focuses)" needs no bookkeeping here (02-architecture.md § Windows). + // `openCard` is the host's too, for a different reason — see the property below. BoardView( store: store, window: { windowController.window }, confirmations: trashConfirmations, - openCard: { cardID in - openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) - } + openCard: openCard ) } // "The board in front", for the menu items that act on it (`LaneWidthCommands`), and @@ -100,6 +100,18 @@ struct BoardWindowHost: View { .focusedSceneValue(\.boardWindowRef, ref) .focusedSceneValue(\.boardInfo, boardInfo) .focusedSceneValue(\.trashConfirmations, trashConfirmations) + // Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item + // and the double-click open one window per card by construction. + .focusedSceneValue(\.cardOpener, cardOpener) + } + } + + /// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it, + /// so "at most one card window per card (reopen focuses)" needs no bookkeeping here + /// (02-architecture.md § Windows). + private var openCard: (ItemID) -> Void { + { cardID in + openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID)) } } @@ -152,6 +164,11 @@ struct BoardWindowHost: View { /// Wires the window: the saved frame on the way in, frame changes on the way back out, the /// close interception that makes the flush unavoidable, and the title-bar widget. private func configureWindow(store: BoardStore, recordID: UUID) { + // Filled in here rather than at declaration because the closure captures `openWindow`, an + // environment action; until the board has loaded there is also nothing for Open Card to act + // on, which is exactly what the item's `nil` check reads. + cardOpener.open = openCard + windowController.onAttach = { window in guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return } window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true) diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 6044c4e..b41fcb7 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -166,16 +166,22 @@ struct KanbanApp: App { ShowTrashCommand() } - // The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…, - // then the width pair, with Open Card and the Move items still owed. Its items act on the + // The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open + // Card, Rename, Style…, the card moves, the lane moves, the width pair. Its items act on the // frontmost board window, which they reach through the focus system rather than through the // app model — see `BoardCommands.swift`, which also owns their validation. CommandMenu("Board") { + OpenCardCommand() BoardRenameCommand() BoardStyleCommand() Divider() + MoveCardCommands() + MoveLaneCommands() + + Divider() + LaneWidthCommands() } diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 276e1d1..c029ba9 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -1101,6 +1101,89 @@ public final class BoardStore { } } + // MARK: - Within-lane sort + + /// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press + /// is not available — **the menu items' `disabled` condition and the write's guard, as one + /// answer** (`LaneWidthCommands`' rule). + /// + /// `nil` covers every refusal the design names in one expression: an empty or tombstoned + /// selection ("⌥⌘↑/⌥⌘↓ are inert *on* tombstoned cards"), a lane selection ("with a lane + /// selected … ⌥⌘↑/⌥⌘↓ are inert"), a card selection that **spans lanes** ("cards never change + /// lanes by ⌘-arrow … so ⌥⌘↑/⌥⌘↓ disable when a card selection spans lanes"), and a block + /// already at the end of its lane. + func sortPlan(_ direction: SortMath.Direction) -> (lane: Lane, ordering: [ItemID])? { + let selection = transient.selection + guard selection.liveness == .live, + SelectionGrammar.kind(of: selection, in: snapshot) == .card, + // `nil` here *is* the spans-lanes case: the helper answers only when one lane holds + // the whole set. + let laneID = Self.lane(holding: selection.ids, in: snapshot), + let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) + else { return nil } + + let rendered = lane.cards.filter { !$0.isDeleted }.map(\.id) + guard let ordering = SortMath.reordered(rendered, moving: selection.ids, direction) else { return nil } + return (lane, ordering) + } + + /// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (04-interactions.md ▸ The map). + /// + /// **One `performWrite` bracket**, like every other batch here: one gesture, one app-mediated + /// reload, one commit on git boards. + /// + /// **The ranks are permuted, not invented.** The lane's existing `order` values, read in display + /// order, are already a sorted ladder of exactly the right length — so the new ordering takes + /// them rung for rung and only the cards whose *position* changed are rewritten. A block stepping + /// past one sibling therefore touches the block plus that sibling and nothing else, which is what + /// keeps `modified` (and, later, a git commit) honest about what actually moved. + /// + /// The one case that ladder cannot serve is **duplicate `order` values**, where display order is + /// decided by the folder-name tie-break (`Ranks.isOrderedForDisplay`) rather than by the rank — + /// permuting equal ranks would write the file and leave the board looking identical. That is the + /// renumber trigger, exactly as an exhausted midpoint is elsewhere: compact the lane, then place + /// against the fresh ladder (`commitPlaceholder`'s and `moveLane`'s pattern). + /// + /// The selection, the anchor and the head are deliberately untouched: every id survives, and the + /// cards the user is moving should stay the cards the user is moving. + public func sortSelection(_ direction: SortMath.Direction) { + guard let plan = sortPlan(direction) else { return } + + let rendered = plan.lane.cards.filter { !$0.isDeleted } + let laneFolder = rootURL.appendingPathComponent(plan.lane.id.rawValue, isDirectory: true) + let orders = rendered.map(\.order) + let positions = Dictionary(uniqueKeysWithValues: rendered.enumerated().map { ($1.id, $0) }) + + try? performWrite { () throws(BoardWriteError) -> Void in + var ladder = orders + if !Self.isStrictlyAscending(orders) { + try BoardWriter.renumberVisibleChildren(of: laneFolder) + // The renumber assigns in display order, so the compacted ladder lines up one-for-one + // with `rendered` — the same alignment `commitPlaceholder` relies on. + ladder = Ranks.renumbered(count: rendered.count) + } + for (destination, id) in plan.ordering.enumerated() { + guard let origin = positions[id], origin != destination else { continue } + let rank = ladder[destination] + try BoardWriter.updateIndex( + inItemFolder: laneFolder.appendingPathComponent(id.rawValue, isDirectory: true), + // `.reorder(title: nil)`: `updateIndex` enriches it off the document it reads, so + // a failure names the card by its own title. + operation: .reorder(title: nil) + ) { document in + document.set(FrontmatterKeys.order, to: .double(rank)) + } + } + } + } + + /// Whether a lane's ranks separate its cards on their own — the condition under which they can + /// be permuted rather than replaced. Ties fall to the folder-name tie-break, which a permutation + /// cannot reach past. + nonisolated static func isStrictlyAscending(_ orders: [Double]) -> Bool { + zip(orders, orders.dropFirst()).allSatisfy { $0 < $1 } + } + // MARK: - The trash /// Whether physically removing an item on this board destroys the only copy of it — and @@ -1138,22 +1221,34 @@ public final class BoardStore { /// only, so a selection the next reload will drop writes nothing rather than re-stamping a /// `deleted:` that is already there. An empty resolution never opens the bracket at all. /// - /// The selection is **cleared**, not moved to a successor. 04-interactions.md ▸ The map asks for - /// the Finder-style successor sibling ("repeated ⌫ walks down a lane"), which needs the - /// navigation order the keyboard grammar defines — that is m5's card. Clearing is the honest - /// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the - /// set a moment later anyway. + /// **The selection moves to the successor sibling** — 04-interactions.md ▸ The map's Finder-style + /// rule ("next card in the lane, next lane on the board; the last sibling's predecessor + /// otherwise; empty container = nothing selected"), whose whole point is that "repeated ⌫ walks + /// down a lane". + /// + /// Two things make that hold. The successor is computed from the **pre-write** snapshot, which is + /// the last one that still knows where the doomed items sat; and it is selected **immediately**, + /// rather than waiting for the reload the tombstone will echo back — a second ⌫ pressed before + /// the watcher rounds the first one back must already have somewhere to land. + /// + /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's + /// reload-survival rule), and neither do `putBack`/`deleteImmediately` — the item merely changed + /// sides, or nothing survives on either. public func delete(_ ids: Set) { let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) } guard !folders.isEmpty else { return } + let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot) try? performWrite { () throws(BoardWriteError) -> Void in for folder in folders { try BoardWriter.deleteItem(at: folder) } } - // m5-keyboard: the successor-selection grammar replaces this line. - clearSelection() + if let successor { + select([successor], liveness: .live, anchor: successor, head: successor) + } else { + clearSelection() + } } /// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket @@ -1314,8 +1409,8 @@ public final class BoardStore { /// for "the lane that most recently held selection or a creation", and a *card* selection is /// its lane holding selection just as much as the lane's own header click is — so both are /// noted here, and creation notes itself in `beginPlaceholder`. - public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil) { - transient.select(ids, liveness: liveness, anchor: anchor) + public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) { + transient.select(ids, liveness: liveness, anchor: anchor, head: head) transient.noteActiveLane(Self.lane(holding: ids, in: snapshot)) } @@ -1342,10 +1437,16 @@ public final class BoardStore { clearSelection() return } - // The anchor is passed through explicitly: `select`'s default would otherwise re-anchor a + // Both cursors are passed through explicitly: `select`'s default would otherwise re-anchor a // ⇧-range's single-member edge case on the target, and the grammar's answer is the one that - // knows whether this click was an origin or an extension. - select(outcome.selection.ids, liveness: outcome.selection.liveness, anchor: outcome.anchor) + // knows whether this click was an origin or an extension. The head is the clicked item in + // every branch — see `SelectionGrammar.Outcome`. + select( + outcome.selection.ids, + liveness: outcome.selection.liveness, + anchor: outcome.anchor, + head: outcome.head + ) } /// **Select All** — "all visible cards on the board" (04-interactions.md ▸ The map), with the @@ -1359,9 +1460,9 @@ public final class BoardStore { /// foreign Put Back, a purge) falls through to the board rather than selecting the trash /// wholesale on a guess. /// - /// The anchor **survives if it is still in the set** and is dropped otherwise: Select All is not - /// a click, so it names no new origin, but it has no business discarding one that is still - /// standing inside what it selected. + /// The anchor — and the navigation head with it — **survives if it is still in the set** and is + /// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor, + /// but it has no business discarding ones that are still standing inside what it selected. /// // m5-search: "filter-respecting, like every surface" (04 ▸ The map). The universe here is // `SelectionGrammar`'s order lists, which is where the filter threads in — one change, and both @@ -1376,14 +1477,15 @@ public final class BoardStore { } /// Select All's storage half: an empty universe clears rather than storing an empty set, and the - /// anchor is kept only while it is still inside what was selected. + /// anchor and head are kept only while they are still inside what was selected. private func apply(_ ids: Set, on side: Liveness) { guard !ids.isEmpty else { clearSelection() return } let anchor = transient.selectionAnchor.flatMap { ids.contains($0) ? $0 : nil } - select(ids, liveness: side, anchor: anchor) + let head = transient.selectionHead.flatMap { ids.contains($0) ? $0 : nil } + select(ids, liveness: side, anchor: anchor, head: head) } /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). diff --git a/Kanban/LiveStore/SelectionGrammar.swift b/Kanban/LiveStore/SelectionGrammar.swift index 5e7de41..49cc143 100644 --- a/Kanban/LiveStore/SelectionGrammar.swift +++ b/Kanban/LiveStore/SelectionGrammar.swift @@ -64,24 +64,31 @@ public enum ClickModifier: Sendable, Equatable { /// trash row) share one answer instead of four near-copies of it. public enum SelectionGrammar { - /// What a click leaves behind: the new selection, and the anchor a subsequent ⇧-click would - /// range from. + /// What a click leaves behind: the new selection, the anchor a subsequent ⇧-click would range + /// from, and the head a subsequent arrow would step from. /// /// The anchor is carried *out* rather than mutated in place because it is not derivable from the /// selection — a ⇧-range replaces the whole set and deliberately leaves the anchor where it was, /// so "the last plain or ⌘ click" is a memory of a gesture and only the gesture can update it /// (`TransientBoardState.selectionAnchor`). + /// + /// **The head is always the clicked item**, in every branch below — the ⇧-branch included, where + /// the anchor deliberately stays put. That asymmetry is the definition of the two: + /// `TransientBoardState.selectionHead` is where the *next* step starts, and a ⇧-click moves it + /// exactly as a plain one does. public struct Outcome: Sendable, Equatable { public var selection: ItemReferenceSet public var anchor: ItemID? + public var head: ItemID? - public init(selection: ItemReferenceSet, anchor: ItemID?) { + public init(selection: ItemReferenceSet, anchor: ItemID?, head: ItemID? = nil) { self.selection = selection self.anchor = anchor + self.head = head } - /// Nothing selected and nothing to range from — the toggle-off outcomes. - static let cleared = Outcome(selection: .empty, anchor: nil) + /// Nothing selected, nothing to range from, nowhere to step from — the toggle-off outcomes. + static let cleared = Outcome(selection: .empty, anchor: nil, head: nil) } /// The grammar, one call. @@ -132,7 +139,8 @@ public enum SelectionGrammar { } return Outcome( selection: ItemReferenceSet(ids: [target.id], liveness: target.side), - anchor: target.id + anchor: target.id, + head: target.id ) } @@ -165,7 +173,8 @@ public enum SelectionGrammar { } return Outcome( selection: ItemReferenceSet(ids: ids, liveness: target.side), - anchor: target.id + anchor: target.id, + head: target.id ) } @@ -184,20 +193,43 @@ public enum SelectionGrammar { anchor: ItemID?, snapshot: BoardModel ) -> Outcome { - let list = order(of: target.kind, on: target.side, in: snapshot) guard let anchor, - let from = list.firstIndex(of: anchor), - let to = list.firstIndex(of: target.id) + let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot) else { return plain(target, selection: selection, togglesOnRepeat: false) } - let span = from <= to ? list[from...to] : list[to...from] return Outcome( - selection: ItemReferenceSet(ids: Set(span), liveness: target.side), - anchor: anchor + selection: ItemReferenceSet(ids: span, liveness: target.side), + anchor: anchor, + head: target.id ) } + /// The ids between two items in one order list, inclusive — **the span both extension gestures + /// select**, ⇧-click and ⇧-arrow alike. + /// + /// It is a function rather than a branch inside `shift` because the keyboard needs the identical + /// answer: "a ⇧-arrow extends" (04-interactions.md ▸ Grammar) means exactly the range a ⇧-click + /// to the same item would produce, and two implementations of one span is two chances for the + /// pointer and the keyboard to disagree about what a range is. + /// + /// **`nil` means the two do not share a list**, which folds the vanished endpoint, the nil + /// anchor's caller-side absence, and every axis crossing into one test — a list is exactly one + /// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain + /// click (it names an unambiguous target), while a ⇧-arrow goes inert (its next step is + /// ambiguous). + public static func range( + from: ItemID, + to: ItemID, + kind: SelectionKind, + on side: Liveness, + in snapshot: BoardModel + ) -> Set? { + let list = order(of: kind, on: side, in: snapshot) + guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil } + return Set(start <= end ? list[start...end] : list[end...start]) + } + // MARK: - The order lists /// The list a ⇧-range walks for one (side, kind) pair — **the single place a "what's on the @@ -284,6 +316,55 @@ public enum SelectionGrammar { } return nil } + + // MARK: - Successor on delete + + /// What ⌫ selects after tombstoning `ids` — 04-interactions.md ▸ The map's Finder-style + /// successor sibling, as a pure function of the **pre-write** snapshot. + /// + /// > Selection moves to the deleted item's successor sibling, Finder-style (next card in the + /// > lane, next lane on the board; the last sibling's predecessor otherwise; empty container = + /// > nothing selected) — repeated ⌫ walks down a lane. + /// + /// Three decisions the wording implies and this states: + /// + /// - **The container is the *last* deleted item's**, in flatten order — the same "last member" + /// the ⌘N target rule and paste anchoring already share. A selection spanning lanes therefore + /// lands in the rightmost/bottom-most one, which is where the user was working. + /// - **The survivor search is forward first, then backward**: the first surviving sibling *after* + /// the last deleted position, else the last surviving sibling *before* the first deleted one. + /// Forward is what makes repeated ⌫ walk down a lane rather than bouncing. + /// - **`nil` is a legitimate answer** — an emptied container selects nothing, and the caller + /// clears. + /// + /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's + /// reload-survival rule: "the selection just shrinks"), which is why this is called by + /// `BoardStore.delete` and by nothing on the reload path. + public static func successor(afterDeleting ids: Set, in snapshot: BoardModel) -> ItemID? { + guard !ids.isEmpty else { return nil } + let selection = ItemReferenceSet(ids: ids, liveness: .live) + guard let kind = kind(of: selection, in: snapshot) else { return nil } + + let container: [ItemID] + switch kind { + case .lane: + container = liveLanes(in: snapshot) + case .card: + // The last selected card in flatten order names the lane; its lane's rendered cards are + // the container the successor is drawn from. + guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }), + let lane = snapshot.lanes.first(where: { lane in + !lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted } + }) + else { return nil } + container = lane.cards.filter { !$0.isDeleted }.map(\.id) + } + + let doomed = container.indices.filter { ids.contains(container[$0]) } + guard let first = doomed.first, let last = doomed.last else { return nil } + if let after = container[(last + 1)...].first(where: { !ids.contains($0) }) { return after } + return container[.. Bool { + /// + /// Shared with `NavigationMath`, which breaks its score ties with it for the same reason: two + /// candidates that a metric cannot separate must still be separated the same way twice. + static func isAbove(_ lhs: MarqueeTarget, _ rhs: MarqueeTarget) -> Bool { if lhs.frame.minY != rhs.frame.minY { return lhs.frame.minY < rhs.frame.minY } if lhs.frame.minX != rhs.frame.minX { return lhs.frame.minX < rhs.frame.minX } return lhs.id.rawValue < rhs.id.rawValue diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index e63674e..ce2fcf3 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -295,6 +295,19 @@ public final class TransientBoardState { /// 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. /// @@ -391,27 +404,34 @@ public final class TransientBoardState { // MARK: - Selection - /// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from. + /// 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**: `nil` with a sole member anchors on that member, `nil` with - /// any other count anchors on 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, - /// while a marquee or a Select All names no click and so leaves a ⇧-click acting plain. + /// 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, liveness: Liveness, anchor: ItemID? = nil) { + public func select(_ ids: Set, liveness: Liveness, anchor: ItemID? = nil, head: ItemID? = nil) { selection = ItemReferenceSet(ids: ids, liveness: liveness) - selectionAnchor = anchor ?? (ids.count == 1 ? ids.first : nil) + 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 goes - /// with it: an empty selection has no origin to range from. + /// 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. @@ -597,15 +617,18 @@ public final class TransientBoardState { if let lane = lastActiveLaneID, !live.contains(lane) { lastActiveLaneID = nil } - if let anchor = selectionAnchor { - // The selection's side, because that is the side the anchor lives on by construction — - // every route that sets it sets the selection to the same side in the same call. A - // vanished or liveness-flipped anchor is gone, which is the rule every item reference - // here gets: "a flip is a vanish from its side of the boundary". + 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 !universe.contains(anchor) { selectionAnchor = nil } + if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } + if let head = selectionHead, !universe.contains(head) { selectionHead = nil } } } diff --git a/Kanban/UI/Board/BoardCommands.swift b/Kanban/UI/Board/BoardCommands.swift index f01a6cc..b27afa8 100644 --- a/Kanban/UI/Board/BoardCommands.swift +++ b/Kanban/UI/Board/BoardCommands.swift @@ -1,3 +1,4 @@ +import Observation import SwiftUI // MARK: - The focused board @@ -20,6 +21,42 @@ extension FocusedValues { } } +// MARK: - The focused board's card opener + +/// How a menu item opens a card window — the board window's own `openCard` closure, published into +/// the focus system beside its store. +/// +/// **It exists because a card window's identity is `(board, card)` and only `BoardWindowHost` holds +/// the board half** (02-architecture.md § Windows). Board ▸ Open Card has no window of its own to +/// derive that from, and the board *view* cannot supply it either — the item is in the menu bar. So +/// the closure travels the same route the store, the popover flag and the purge-alert host already +/// do. +/// +/// A small reference type rather than a value, for `BoardInfoPresentation`'s reason: it is the +/// window's, one per window, and it is filled in after the board has loaded — `@Observable` so the +/// menu item's validation notices when it is. +@MainActor +@Observable +final class CardOpener { + + /// `nil` until the window's board has loaded, which is also exactly when Open Card has nothing + /// to act on. + var open: ((ItemID) -> Void)? + + init() {} +} + +struct FocusedCardOpenerKey: FocusedValueKey { + typealias Value = CardOpener +} + +extension FocusedValues { + var cardOpener: CardOpener? { + get { self[FocusedCardOpenerKey.self] } + set { self[FocusedCardOpenerKey.self] = newValue } + } +} + // MARK: - Shared validation /// The two conditions **every** board-mutating menu item disables on, in one place. @@ -31,8 +68,8 @@ extension FocusedValues { /// editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete, /// New Card, Paste, Move, Style, …) disable via menu validation" and the keyboard belongs to the /// text domain. The one carve-out the design names is Open Card ⌘↩, which stays enabled to commit -/// the edit and open the window — it is not a menu item yet (m5), and when it is, it is the one -/// item that must *not* read this property. +/// the edit and open the window — `OpenCardCommand` below is therefore the one item that +/// deliberately does not read this property. /// /// Stated once rather than repeated per item, because the interesting failure mode is an item that /// quietly forgets half of it. @@ -42,6 +79,172 @@ extension BoardStore { } } +// MARK: - Open Card + +/// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command +/// enabled mid-edit** (04-interactions.md ▸ Grammar's focused-editor rule). +/// +/// Two contexts, exactly as the Nexus scopes them: "sole selected live card; during an inline title +/// edit (placeholder or rename), commits it and opens". So this is the single item that must *not* +/// read `acceptsBoardMutations` — the open-editor half of that property is the very state it exists +/// to serve. It does not read the read-only lock either: opening a window is not a mutation, and the +/// commit path it may run through refuses on its own with the lock's row already standing. +/// +/// The mid-edit branches mirror the pointer twins exactly rather than reimplementing them — the +/// placeholder's is `NewCardStubView.commit()` (read the lane, commit, re-select the surviving lane) +/// and the rename's is `LaneView`'s `onCommitAndOpen` (a lane rename just commits; only a card has a +/// window to open). Both stores' commits no-op against a closed editor, so this item and +/// `InlineTitleField`'s own ⌘↩ fallback compose without acting twice. +struct OpenCardCommand: View { + + @FocusedValue(\.boardStore) private var store + @FocusedValue(\.cardOpener) private var opener + + var body: some View { + Button("Open Card") { + open() + } + .keyboardShortcut(.return, modifiers: .command) + .disabled(!isEnabled) + } + + private var isEnabled: Bool { + guard let store, opener?.open != nil else { return false } + return store.isEditingInline || soleSelectedCard != nil + } + + /// The sole selected **live card**, or `nil`. A lane, a multi-selection and a tombstoned + /// selection all answer `nil` — "everything edit-shaped is disabled on tombstoned selections" + /// (04 ▸ The trash), and a card window is tied to one card. + private var soleSelectedCard: ItemID? { + guard let store else { return nil } + let selection = store.selection + guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first, + BoardStore.liveItem(id, in: store.snapshot)?.cardID != nil + else { return nil } + return id + } + + private func open() { + guard let store, let open = opener?.open else { return } + + if let placeholder = store.transient.newCardPlaceholder { + // The lane is read before the commit, because every discard path clears the overlay that + // holds it — and re-checked after, because one of those paths is *the lane vanished*. + let lane = placeholder.laneID + let created = store.commitPlaceholder() + if store.snapshot.lanes.contains(where: { $0.id == lane && !$0.isDeleted }) { + store.select([lane], liveness: .live) + } + if let created { open(created) } + return + } + + if let editor = store.transient.renameEditor { + let target = editor.targetID + let isCard = BoardStore.liveItem(target, in: store.snapshot)?.cardID != nil + store.commitRename() + if isCard { open(target) } + return + } + + if let card = soleSelectedCard { open(card) } + } +} + +// MARK: - Within-lane sort + +/// Board ▸ Move Up / Move Down (⌥⌘↑/⌥⌘↓) — the within-lane sort (11-command-nexus.md; +/// 04-interactions.md ▸ The map, where the chord is settled as the "⌥⌘ modifies" family's vertical +/// half alongside the lane-width pair). +/// +/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the +/// items disable on everything the design calls inert — a lane selection, a tombstoned selection, a +/// card selection spanning lanes ("cards never change lanes by ⌘-arrow") — and additionally on a +/// block already at its lane's end, where the only outcome would be a silent no-op. +/// +/// The direction matters to that answer, which is why each item asks separately: a block at the top +/// disables Move Up while Move Down stays live. +struct MoveCardCommands: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Move Up") { + store?.sortSelection(.up) + } + .keyboardShortcut(.upArrow, modifiers: [.option, .command]) + .disabled(!canSort(.up)) + + Button("Move Down") { + store?.sortSelection(.down) + } + .keyboardShortcut(.downArrow, modifiers: [.option, .command]) + .disabled(!canSort(.down)) + } + + private func canSort(_ direction: SortMath.Direction) -> Bool { + guard let store, store.acceptsBoardMutations else { return false } + return store.sortPlan(direction) != nil + } +} + +// MARK: - Lane moves + +/// Board ▸ Move Left / Move Right (⌘←/⌘→) — "Lane selection only (one slot; never into the trash)" +/// (11-command-nexus.md), closing 10-accessibility.md's lane-move defect (04 ▸ Accessibility). +/// +/// **Sole lane, deliberately.** The width pair one row below explicitly batches over a multi-lane +/// selection; this row's inventory line says "Lane selection only" with no batching clause, and a +/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one +/// answer). So the items validate on exactly one selected live lane. +/// +/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step +/// past the last real lane is simply off the end — which is also the disable rule at the walls, +/// following the width stepper's floor style rather than letting the store no-op silently. +struct MoveLaneCommands: View { + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Move Left") { + move(by: -1) + } + .keyboardShortcut(.leftArrow, modifiers: .command) + .disabled(destination(-1) == nil) + + Button("Move Right") { + move(by: 1) + } + .keyboardShortcut(.rightArrow, modifiers: .command) + .disabled(destination(1) == nil) + } + + /// The sole selected live lane and the display slot one step would put it in — `nil` when there + /// is no such lane or it is already at that wall. + /// + /// `from + delta` **is** the index `moveLane` wants: that method counts display positions among + /// the live lanes *with the moved lane already removed*, so inserting at `from - 1` puts the lane + /// before its old predecessor and at `from + 1` after its old successor — one slot each way. The + /// convention is easy to get backwards, which is why it is pinned by a test. + private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? { + guard let store, store.acceptsBoardMutations else { return nil } + let selection = store.selection + guard selection.liveness == .live, selection.ids.count == 1, let id = selection.ids.first else { return nil } + let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + // A card id is in no lane order, so this is also the "not a lane" test. + guard let from = lanes.firstIndex(of: id) else { return nil } + let to = from + delta + guard lanes.indices.contains(to) else { return nil } + return (id, to) + } + + private func move(by delta: Int) { + guard let store, let target = destination(delta) else { return } + store.moveLane(target.lane, toIndex: target.index) + } +} + // MARK: - Creation items /// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows. diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 44bc530..c8691eb 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -23,8 +23,11 @@ import SwiftUI /// order. /// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`, /// `MarqueeMath`); the strip owns the session and the target registry, and hands both down. -/// - **The keyboard's narrow slice** — Return's create/rename dispatch, Escape's step outward, and -/// Select All. +/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four +/// arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s tombstone, Escape's step +/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything +/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md ▸ +/// Configurable bindings draws between what remaps and what does not. /// /// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as /// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash). @@ -155,9 +158,16 @@ struct BoardView: View { // after every rename and Return silently stops working. if !editing { isBoardFocused = true } } - .onKeyPress(.return) { handleReturn() } + .onKeyPress(keys: [.return], phases: .down) { handleReturn($0) } .onKeyPress(.escape) { handleEscape() } .onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) } + // **The arrows** (04-interactions.md ▸ Grammar), on `.down` *and* `.repeat`: holding an + // arrow must walk the board, and a handler registered for `.down` alone sees the first + // press only. + .onKeyPress( + keys: [.upArrow, .downArrow, .leftArrow, .rightArrow], + phases: [.down, .repeat] + ) { handleArrow($0) } // **Select All** (04-interactions.md ▸ The map). Edit ▸ Select All is the standard menu // item and it dispatches `selectAll:` down the responder chain, so the board answers it as a // responder rather than growing a second menu item with the same title — which titles-are-API @@ -470,13 +480,17 @@ struct BoardView: View { /// everything else is ignored — a multi-card selection is explicitly inert, and a lane's rename /// path is Board ▸ Rename precisely because Return on a lane creates. /// - /// The full keyboard map — arrows, ⌥-jumps, the ⌥↑ escalation, ⌫, the ⌥⌘ moves — is **m5's - /// keyboard-grammar card**. This is the creation/rename pair and nothing else. - /// /// Inert while an inline editor is open: "all grammar keys inert while a title editor is /// focused". The field consumes Return itself, so this guard is belt over braces — but the belt /// matters, because a stray Return reaching here mid-edit would open a *second* editor. - private func handleReturn() -> KeyPress.Result { + private func handleReturn(_ press: KeyPress) -> KeyPress.Result { + // **Plain Return only**, the delete handler's rule for its reason. ⌘↩ belongs to Board ▸ + // Open Card and AppKit routes it to the menu first — but only while that item is *enabled*, + // and a disabled one lets the chord fall through to here. ⌥↩ and ⇧↩ are nobody's key + // equivalent at all. Neither may open a rename or a placeholder. + guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else { + return .ignored + } guard !store.isEditingInline, !store.isReadOnly else { return .ignored } let selection = store.selection guard selection.liveness == .live, @@ -538,6 +552,319 @@ struct BoardView: View { store.clearSelection() return .handled } + + // MARK: - The arrows + + /// What modifier an arrow carried, reduced to the three meanings the grammar gives it — the + /// keyboard's `ClickModifier`. + private enum ArrowMode { + /// Plain: spatial navigation, replacing the selection. + case step + /// ⇧: extend the range from the anchor. + case extend + /// ⌥: jump to an end (04-interactions.md ▸ Grammar's "⌥-arrows jump"). + case jump + } + + /// **The arrow grammar's one door** (04-interactions.md ▸ Grammar; 11-command-nexus.md ▸ Fixed + /// grammar keys). + /// + /// The handlers below are deliberately thin over pure functions — `NavigationMath` for the + /// geometry, `SelectionGrammar` for the order lists and the ranges — so what is written here is + /// dispatch and nothing else. + /// + /// **⌘- and ⌥⌘-arrows never mean anything here.** They are menu key equivalents (Move Left/Right, + /// Move Up/Down, the lane width pair) and AppKit routes them to the menu before any view sees + /// them — but only while the item is *enabled*, so a disabled Move Right does deliver ⌘→ here. + /// Rejecting every combination but plain, ⇧ and ⌥ is what keeps a disabled command from silently + /// becoming a navigation gesture, and a mistyped text chord from moving the selection. + private func handleArrow(_ press: KeyPress) -> KeyPress.Result { + // "All grammar keys inert while a title editor is focused" — and the field owns the arrows + // as caret movement, so this guard is load-bearing rather than belt over braces. + guard !store.isEditingInline else { return .ignored } + guard let direction = Self.direction(of: press.key) else { return .ignored } + + // Only the four meaningful flags are read: an arrow event also carries `.function` and + // `.numericPad` on macOS, and testing the whole set for emptiness would reject every press. + let modifiers = press.modifiers.intersection([.command, .control, .option, .shift]) + let mode: ArrowMode + if modifiers.isEmpty { + mode = .step + } else if modifiers == .shift { + mode = .extend + } else if modifiers == .option { + mode = .jump + } else { + return .ignored + } + + guard let origin = arrowOrigin() else { return seed(direction, mode) } + return origin.isLaneDomain + ? laneArrow(direction, mode, from: origin.head) + : cardArrow(direction, mode, from: origin.head, on: origin.side) + } + + private static func direction(of key: KeyEquivalent) -> NavigationMath.Direction? { + switch key.character { + case KeyEquivalent.upArrow.character: .up + case KeyEquivalent.downArrow.character: .down + case KeyEquivalent.leftArrow.character: .left + case KeyEquivalent.rightArrow.character: .right + default: nil + } + } + + /// Where the next arrow steps from, and on which of the board's two levels — `nil` when the + /// selection names nothing to step from, which is the seed rule's cue. + /// + /// The head is `TransientBoardState.selectionHead` when it is still in the order list, and + /// otherwise the selection's **last member in that list** — the same "last in flatten order" + /// anchor the ⌘N target rule and paste already share. That fallback is what makes a marquee, a + /// Select All and a foreign reload leave the arrows somewhere sensible without any of them + /// having to name a cursor. + /// + /// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk + /// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The + /// per-kind lists are the *range*'s business, not the walk's. + private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? { + let selection = store.selection + guard !selection.isEmpty else { return nil } + + let isLaneDomain: Bool + let list: [ItemID] + switch selection.liveness { + case .live: + guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil } + isLaneDomain = kind == .lane + list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot) + case .trashed: + isLaneDomain = false + list = TrashModel.entries(of: store.snapshot).map(\.id) + } + + if let head = store.transient.selectionHead, list.contains(head) { + return (head, selection.liveness, isLaneDomain) + } + guard let last = list.last(where: { selection.ids.contains($0) }) else { return nil } + return (last, selection.liveness, isLaneDomain) + } + + /// **An empty selection seeds at the first lane's first card** (04-interactions.md ▸ Grammar) — + /// a deterministic origin, so an arrow from nothing always means the same thing. + /// + /// ⌥←/⌥→ are the exception, and the design states it: "the ⌥-jumps behave as specified + /// regardless". Those two name an *absolute* destination and need no origin, so they run + /// unchanged. ⌥↑/⌥↓ are relative to "the current lane", which an empty selection has none of, so + /// they seed like a plain arrow — which is exactly what makes "two ⌥↑ presses from nothing reach + /// the lane domain" true: the first seeds, the second escalates. + private func seed(_ direction: NavigationMath.Direction, _ mode: ArrowMode) -> KeyPress.Result { + if mode == .jump, direction == .left || direction == .right { + return jumpToEndLane(direction) + } + guard let first = Self.firstCard(scanning: liveLanes) else { return .handled } + replaceSelection(with: first, on: .live) + return .handled + } + + // MARK: Card domain + + private func cardArrow( + _ direction: NavigationMath.Direction, + _ mode: ArrowMode, + from head: ItemID, + on side: Liveness + ) -> KeyPress.Result { + switch mode { + case .step: step(direction, from: head) + case .extend: extend(direction, from: head) + case .jump: + switch direction { + case .left, .right: jumpToEndLane(direction) + case .up, .down: jumpWithinContainer(direction, from: head, on: side) + } + } + } + + /// **Nearest card in the direction, across interior grid columns and lanes** — and across the + /// live/trash boundary too, since "plain arrows still walk across" (04 ▸ The trash). + /// + /// Every registered target is a candidate, which is also how the hidden trash stays invisible: + /// a column that is not drawn registers nothing. + private func step(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result { + guard let origin = marqueeTargets.targets[head], + let nextID = NavigationMath.nearest( + from: origin.frame, + direction: direction, + among: marqueeTargets.all + ), + let next = marqueeTargets.targets[nextID] + else { return .handled } + replaceSelection(with: next.id, on: next.side) + return .handled + } + + /// **⇧-arrow extends, and stops at both boundaries** (04 ▸ The trash, settled): "a ⇧-arrow whose + /// next step would cross from live cards into the trash (or back), or from card entries onto a + /// lane entry within it, is simply inert". + /// + /// The *step* that would cross is what goes inert — the crossing item is never stepped over in + /// search of a legal one, because that would silently drop the held range for a longer reach + /// than the user asked for. So the nearest neighbour is computed **unrestricted** and then + /// tested: a different side or a different kind means this press does nothing at all. + private func extend(_ direction: NavigationMath.Direction, from head: ItemID) -> KeyPress.Result { + guard let origin = marqueeTargets.targets[head], + let nextID = NavigationMath.nearest( + from: origin.frame, + direction: direction, + among: marqueeTargets.all + ), + let next = marqueeTargets.targets[nextID], + next.side == origin.side, + next.kind == origin.kind + else { return .handled } + + // An extension with no anchor makes one of where it started — the keyboard's equivalent of + // a ⇧-click after a marquee, which the grammar degrades to a plain click for the same + // reason: a range needs an origin, and the only honest one is the cursor's own position. + let anchor = store.transient.selectionAnchor ?? head + guard let ids = SelectionGrammar.range( + from: anchor, + to: next.id, + kind: next.kind, + on: next.side, + in: store.snapshot + ) else { return .handled } + store.select(ids, liveness: next.side, anchor: anchor, head: next.id) + return .handled + } + + /// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash + /// quasi-lane's when that is where the cursor is. + /// + /// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to + /// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects + /// the *lane* itself. The trash deliberately never escalates: it "is never selectable as a lane", + /// so a second ⌥↑ there is simply inert. + private func jumpWithinContainer( + _ direction: NavigationMath.Direction, + from head: ItemID, + on side: Liveness + ) -> KeyPress.Result { + let container: [ItemID] + var lane: ItemID? + switch side { + case .trashed: + container = TrashModel.entries(of: store.snapshot).map(\.id) + case .live: + guard let home = store.snapshot.lanes.first(where: { lane in + !lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted } + }) else { return .handled } + lane = home.id + container = home.cards.filter { !$0.isDeleted }.map(\.id) + } + + guard let target = direction == .up ? container.first : container.last else { return .handled } + if direction == .up, target == head, let lane, store.selection.ids == [head] { + replaceSelection(with: lane, on: .live) + return .handled + } + replaceSelection(with: target, on: side) + return .handled + } + + /// **⌥←/⌥→ to the first/last lane** (04 ▸ Grammar) — landing, in the card domain, on that lane's + /// first card, since ⌥↑ is the one keyboard entry to lane selection. + /// + /// **⌥→ reaches the shown trash** first (04 ▸ The trash: "the shown trash is the last container + /// for card navigation, and ⌥→ jumps to it"); an empty or hidden column is not a destination, so + /// the jump falls through to the last lane. Empty lanes are scanned past in both directions — + /// a jump that landed nowhere because the end lane happens to be empty would be a dead key. + private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result { + if direction == .right, isTrashVisible, let first = TrashModel.entries(of: store.snapshot).first { + replaceSelection(with: first.id, on: .trashed) + return .handled + } + let lanes = liveLanes + let target = direction == .right + ? Self.firstCard(scanning: lanes.reversed()) + : Self.firstCard(scanning: lanes) + guard let target else { return .handled } + replaceSelection(with: target, on: .live) + return .handled + } + + // MARK: Lane domain + + /// The arrows with a **lane** selected (04-interactions.md ▸ Grammar, ▸ The map). + /// + /// - ←/→ move the lane selection one lane, inert at the ends — **and the trash is never reached** + /// ("with a lane selected, ←/→ and ⌥→ stop at the last real lane"), which falls out for free + /// from walking the live lane order and nothing else. + /// - ⇧←/⇧→ extend that selection from the anchor, the same range a ⇧-click would give. + /// - ↓ descends back into the lane's cards at the first card, ⌥↓ at the last; an empty lane has + /// nothing to descend into. + /// - ↑ and ⌥↑ are inert: the lane domain is the top of the hierarchy. + /// - ⌥←/⌥→ jump to the first/last lane, staying in the lane domain. + private func laneArrow( + _ direction: NavigationMath.Direction, + _ mode: ArrowMode, + from head: ItemID + ) -> KeyPress.Result { + let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + guard let index = lanes.firstIndex(of: head) else { return .handled } + + switch (direction, mode) { + case (.left, .step), (.right, .step), (.left, .extend), (.right, .extend): + let next = index + (direction == .left ? -1 : 1) + guard lanes.indices.contains(next) else { return .handled } + if mode == .step { + replaceSelection(with: lanes[next], on: .live) + } else { + let anchor = store.transient.selectionAnchor ?? head + guard let ids = SelectionGrammar.range( + from: anchor, + to: lanes[next], + kind: .lane, + on: .live, + in: store.snapshot + ) else { return .handled } + store.select(ids, liveness: .live, anchor: anchor, head: lanes[next]) + } + + case (.left, .jump), (.right, .jump): + guard let target = direction == .left ? lanes.first : lanes.last else { return .handled } + replaceSelection(with: target, on: .live) + + case (.down, .step), (.down, .jump): + guard let lane = store.snapshot.lanes.first(where: { $0.id == head && !$0.isDeleted }) else { + return .handled + } + let cards = lane.cards.filter { !$0.isDeleted } + guard let target = mode == .jump ? cards.last : cards.first else { return .handled } + replaceSelection(with: target.id, on: .live) + + case (.up, _), (.down, .extend): + // Nothing above the lane domain, and no vertical range within it. + break + } + return .handled + } + + // MARK: Shared + + /// A jump's and a plain step's shared landing: one item, both cursors on it. + private func replaceSelection(with id: ItemID, on side: Liveness) { + store.select([id], liveness: side, anchor: id, head: id) + } + + /// The first rendered card of the first lane that has one — the scan every "first/last lane" + /// destination shares, run over the lane order forwards or reversed. + private static func firstCard(scanning lanes: some Sequence) -> ItemID? { + for lane in lanes { + if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id } + } + return nil + } } // MARK: - Resize shadow diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index ecc4ee0..20598ff 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -313,7 +313,23 @@ struct LaneView: View { /// The card stack. Its empty space is a click target in its own right (04 ▸ Selection): one /// click selects the lane or, when it is already the selection, clears it; a double click /// creates a card at the bottom with its title editor focused. + /// + /// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar): the reader watches the + /// navigation head — the cursor the arrows move, not the whole selection — and scrolls only when + /// the head names a card *this* lane renders, so exactly one lane responds to any one press. + /// Deliberately unwrapped by `withAnimation`: 03-board-ui.md § Motion has selection follow + /// "whatever transaction is active rather than easing on its own". private var cardStack: some View { + ScrollViewReader { proxy in + scrollableCards + .onChange(of: store.transient.selectionHead) { _, head in + guard let head, let card = renderedCards.first(where: { $0.id == head }) else { return } + proxy.scrollTo(LaneSlot.identity(of: card.id)) + } + } + } + + private var scrollableCards: some View { ScrollView(.vertical) { // Cards stay standard width whatever the lane spans: at a slot width of // `units × standard + (units - 1) × gap`, `MasonryLayout` divides back into exactly @@ -340,6 +356,10 @@ struct LaneView: View { // decided upstream — at the reload for the real cards (`Motion.reloadAnimates`), // at the gesture for the placeholder, which touches no disk. .transition(Motion.cardTransition(reduced: reduceMotion)) + // The scroll target. `ForEach` already carries this identity, but `scrollTo` + // resolves against an explicit `.id`, and it goes outermost so the transition + // above stays inside the identified view rather than around it. + .id(slot.id) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -447,12 +467,16 @@ private enum LaneSlot: Identifiable { var id: String { switch self { - case let .card(card): "card:\(card.id.rawValue)" + case let .card(card): Self.identity(of: card.id) // Constant, because there is only ever one placeholder in one lane at a time and it must // keep its identity — and therefore its keyboard focus — while the user types. case .placeholder: "placeholder" } } + + /// A card slot's id, spelled once so the scroll-into-view call and the slot itself cannot + /// disagree about what `scrollTo` is looking for. + static func identity(of card: ItemID) -> String { "card:\(card.rawValue)" } } // MARK: - Card face diff --git a/Kanban/UI/Board/NavigationMath.swift b/Kanban/UI/Board/NavigationMath.swift new file mode 100644 index 0000000..2cca128 --- /dev/null +++ b/Kanban/UI/Board/NavigationMath.swift @@ -0,0 +1,156 @@ +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. +/// +/// 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 + } +} + +// 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, + _ 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[.. WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) + try fixture.item("\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "4096", title: "Fourth")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane2)/\(More.card5)", Item.rich(order: "1024", title: "Fifth")) + try fixture.item("\(Ident.lane2)/\(More.card6)", Item.rich(order: "2048", title: "Sixth")) + try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) + return fixture +} + +private func load(_ fixture: WriterFixture) throws -> BoardModel { + try BoardLoader.load(boardRoot: fixture.root).model +} + +/// The ids a lane renders, top to bottom, as they are **on disk right now**. +private func cardOrder(_ laneID: String, in fixture: WriterFixture) throws -> [ItemID] { + let model = try load(fixture) + let lane = try #require(model.lanes.first { $0.id.rawValue == laneID }) + return lane.cards.filter { !$0.isDeleted }.map(\.id) +} + +// MARK: - Frames + +/// A drawn frame, with the two axes the score reads spelled out at the call site. +private func target( + _ id: ItemID, + x: CGFloat, + y: CGFloat, + width: CGFloat = 100, + height: CGFloat = 100, + kind: SelectionKind = .card, + side: Liveness = .live +) -> MarqueeTarget { + MarqueeTarget(id: id, kind: kind, side: side, frame: CGRect(x: x, y: y, width: width, height: height)) +} + +/// A two-by-two grid: `card1` `card3` on the top row, `card2` `card4` beneath them — the smallest +/// board shape with an interior column *and* a lane boundary to cross. +private let grid: [MarqueeTarget] = [ + target(card1, x: 0, y: 0), + target(card2, x: 0, y: 120), + target(card3, x: 120, y: 0), + target(card4, x: 120, y: 120) +] + +private let originFrame = CGRect(x: 0, y: 0, width: 100, height: 100) + +// MARK: - NavigationMath + +@Suite("NavigationMath ▸ nearest in the direction") +struct NavigationMathTests { + + @Test("Each direction picks its own neighbour") + func fourDirections() { + #expect(NavigationMath.nearest(from: grid[0].frame, direction: .down, among: grid) == card2) + #expect(NavigationMath.nearest(from: grid[1].frame, direction: .up, among: grid) == card1) + #expect(NavigationMath.nearest(from: grid[0].frame, direction: .right, among: grid) == card3) + #expect(NavigationMath.nearest(from: grid[2].frame, direction: .left, among: grid) == card1) + } + + @Test("A card straight ahead beats a nearer one off to the side — orthogonal drift costs double") + func orthogonalDriftIsPenalised() { + // Straight down at 100pt of primary distance (score 100) versus 40pt down but 200pt across + // (score 40 + 400). Without the penalty the second would win and ↓ would wander out of the + // column instead of walking it (04-interactions.md ▸ Grammar). + let straight = target(card2, x: 0, y: 100) + let sideways = target(card3, x: 400, y: 40) + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [straight, sideways]) == card2) + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [sideways, straight]) == card2) + } + + @Test("A tie is broken by position, then identity — and the input order never decides") + func tiesAreDeterministic() { + // Both score 50 + 2 × 50: same primary distance, same drift, opposite sides. + let right = target(card2, x: 50, y: 50) + let left = target(card3, x: -50, y: 50) + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [right, left]) == card3) + #expect( + NavigationMath.nearest(from: originFrame, direction: .down, among: [left, right]) == card3, + "reversing the candidate list must not change the answer" + ) + + // Same frame twice: position cannot separate them, so identity does. + let low = target(ItemID(rawValue: "aaaa"), x: 0, y: 200) + let high = target(ItemID(rawValue: "zzzz"), x: 0, y: 200) + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [high, low])?.rawValue == "aaaa") + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [low, high])?.rawValue == "aaaa") + } + + @Test("Nothing beyond the origin in that direction is nil, and the origin never picks itself") + func noCandidate() { + #expect(NavigationMath.nearest(from: grid[0].frame, direction: .up, among: grid) == nil) + #expect(NavigationMath.nearest(from: grid[0].frame, direction: .left, among: grid) == nil) + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: []) == nil) + // A candidate level with the origin is not beyond it: the 1pt threshold excludes the origin + // itself and its exact row-mates. + #expect(NavigationMath.nearest(from: originFrame, direction: .down, among: [target(card2, x: 300, y: 0)]) == nil) + } + + @Test("The predicate is the ⇧-arrow's restriction — the trash side is simply not a candidate") + func predicateRestrictsCandidates() { + let trashed = target(card2, x: 0, y: 100, side: .trashed) + let live = target(card3, x: 0, y: 400) + let all = [trashed, live] + + #expect( + NavigationMath.nearest(from: originFrame, direction: .down, among: all) == card2, + "a plain arrow walks across the boundary" + ) + #expect( + NavigationMath.nearest(from: originFrame, direction: .down, among: all, where: { $0.side == .live }) == card3 + ) + } +} + +// MARK: - SortMath + +@Suite("SortMath ▸ within-lane sort") +struct SortMathTests { + + private let ordered = [card1, card2, card3, card4] + + @Test("A contiguous block steps one position, hopping its neighbour") + func stepsOnePosition() { + #expect(SortMath.reordered(ordered, moving: [card3], .up) == [card1, card3, card2, card4]) + #expect(SortMath.reordered(ordered, moving: [card2], .down) == [card1, card3, card2, card4]) + #expect(SortMath.reordered(ordered, moving: [card2, card3], .up) == [card2, card3, card1, card4]) + #expect(SortMath.reordered(ordered, moving: [card2, card3], .down) == [card1, card4, card2, card3]) + } + + @Test("A non-contiguous selection gathers behind its first card, relative order preserved") + func gathersOnTheFirstPress() { + // "Anchored at the first selected card (first = lowest logical order; the rest follow in + // preserved relative order)" — and the press that gathers does not also step, which is why + // both directions give the same answer. + #expect(SortMath.reordered(ordered, moving: [card2, card4], .up) == [card1, card2, card4, card3]) + #expect(SortMath.reordered(ordered, moving: [card2, card4], .down) == [card1, card2, card4, card3]) + #expect(SortMath.reordered(ordered, moving: [card1, card3], .up) == [card1, card3, card2, card4]) + #expect( + SortMath.reordered(ordered, moving: [card1, card4], .down) == [card1, card4, card2, card3], + "the unselected cards keep their relative order around the block" + ) + } + + @Test("At the ladder's end, and with nothing to move, the answer is nil rather than a no-op write") + func edgesAndEmptyAreNil() { + #expect(SortMath.reordered(ordered, moving: [card1], .up) == nil) + #expect(SortMath.reordered(ordered, moving: [card4], .down) == nil) + #expect(SortMath.reordered(ordered, moving: [card1, card2], .up) == nil) + #expect(SortMath.reordered(ordered, moving: Set(ordered), .up) == nil) + #expect(SortMath.reordered(ordered, moving: Set(ordered), .down) == nil) + #expect(SortMath.reordered(ordered, moving: [], .up) == nil) + #expect(SortMath.reordered([card1], moving: [card1], .down) == nil, "a lane of one has nowhere to go") + #expect( + SortMath.reordered(ordered, moving: [card5], .up) == nil, + "ids the lane does not render are ignored, so a stale selection moves nothing" + ) + } +} + +// MARK: - The successor rule + +@MainActor +@Suite("SelectionGrammar ▸ successor on delete") +struct SuccessorTests { + + @Test("The next card in the lane, so repeated ⌫ walks down it") + func nextCardInTheLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(SelectionGrammar.successor(afterDeleting: [card2], in: snapshot) == card3) + #expect(SelectionGrammar.successor(afterDeleting: [card1], in: snapshot) == card2) + #expect( + SelectionGrammar.successor(afterDeleting: [card1, card2], in: snapshot) == card3, + "a block's successor is the first survivor after its last member" + ) + } + + @Test("The last sibling falls back to its predecessor") + func predecessorFallback() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(SelectionGrammar.successor(afterDeleting: [card4], in: snapshot) == card3) + #expect(SelectionGrammar.successor(afterDeleting: [card3, card4], in: snapshot) == card2) + } + + @Test("A survivor between the members is found forwards first") + func forwardSearchWinsOverBackward() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + // Doomed at positions 0 and 2: forward from the last one finds card4, which is what makes + // repeated ⌫ keep moving down rather than bouncing back up the lane. + #expect(SelectionGrammar.successor(afterDeleting: [card1, card3], in: snapshot) == card4) + } + + @Test("An emptied container selects nothing") + func emptiedContainerIsNil() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(SelectionGrammar.successor(afterDeleting: [card1, card2, card3, card4], in: snapshot) == nil) + #expect(SelectionGrammar.successor(afterDeleting: [], in: snapshot) == nil) + #expect( + SelectionGrammar.successor(afterDeleting: [ItemID(rawValue: "nobody")], in: snapshot) == nil, + "ids naming nothing name no container either" + ) + } + + @Test("A cross-lane selection is answered in its last member's lane, in flatten order") + func crossLaneUsesTheLastMembersLane() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + // card5 is later than card2 in flatten order (lane `order`, then card `order`), so the + // container is lane2 — the same "last member" anchor ⌘N and paste already share. + #expect(SelectionGrammar.successor(afterDeleting: [card2, card5], in: snapshot) == card6) + } + + @Test("Lanes follow the same rule in the live lane order") + func laneSuccessors() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + #expect(SelectionGrammar.successor(afterDeleting: [lane1], in: snapshot) == lane2) + #expect(SelectionGrammar.successor(afterDeleting: [lane3], in: snapshot) == lane2, "the last lane's predecessor") + #expect(SelectionGrammar.successor(afterDeleting: [lane1, lane2, lane3], in: snapshot) == nil) + } + + @Test("⌫ selects the successor immediately, before the reload echoes the tombstone back") + func deleteSelectsTheSuccessor() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card2], liveness: .live) + store.deleteSelection() + + #expect(store.selection.ids == [card3]) + #expect(store.transient.selectionAnchor == card3, "the successor is a legitimate range origin") + #expect(store.transient.selectionHead == card3, "and the place the next arrow steps from") + + // Repeated ⌫ walks down the lane — the whole point of the rule. The store's snapshot has not + // reloaded, so card2 is still in it and card3's successor is card4. + store.deleteSelection() + #expect(store.selection.ids == [card4]) + } + + @Test("An emptied lane clears the selection instead of inventing one") + func deleteClearsWhenNothingSurvives() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card5, card6], liveness: .live) + store.deleteSelection() + + #expect(store.selection.isEmpty) + #expect(store.transient.selectionHead == nil) + } +} + +// MARK: - The navigation head + +@MainActor +@Suite("TransientBoardState ▸ the navigation head") +struct SelectionHeadTests { + + @Test("A sole member is its own head; any other count leaves none") + func headDefaults() throws { + let state = TransientBoardState() + + state.select([card1], liveness: .live) + #expect(state.selectionHead == card1) + + state.select([card1, card2], liveness: .live) + #expect(state.selectionHead == nil, "a set with no gesture behind it names no cursor") + + state.select([card1, card2], liveness: .live, anchor: card1, head: card2) + #expect(state.selectionAnchor == card1) + #expect(state.selectionHead == card2, "an explicit head is kept whatever the count") + + state.clearSelection() + #expect(state.selectionHead == nil) + #expect(state.selectionAnchor == nil) + } + + @Test("A ⇧-gesture moves the head and leaves the anchor — that asymmetry is why both exist") + func shiftMovesOnlyTheHead() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try load(fixture) + + let outcome = SelectionGrammar.click( + SelectionTarget(id: card3, kind: .card, side: .live), + modifier: .shift, + selection: ItemReferenceSet(ids: [card1], liveness: .live), + anchor: card1, + snapshot: snapshot + ) + + #expect(outcome.selection.ids == [card1, card2, card3]) + #expect(outcome.anchor == card1, "the range origin stays put") + #expect(outcome.head == card3, "the cursor walks to what was clicked") + } + + @Test("A vanished head is dropped by the reload, like every other item reference") + func resolveDropsAVanishedHead() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1, card2], liveness: .live, anchor: card1, head: card2) + + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card2)")) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(store.selection.ids == [card1]) + #expect(store.transient.selectionHead == nil) + #expect(store.transient.selectionAnchor == card1, "the anchor survived — it is still in the tree") + } + + @Test("A liveness flip is a vanish for the head too") + func resolveDropsALivenessFlippedHead() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1], liveness: .live) + #expect(store.transient.selectionHead == card1) + + try fixture.item("\(Ident.lane1)/\(Ident.card1)", """ + --- + schema: 1 + title: First + order: 1024 + deleted: 2026-03-03T09:00:00Z + --- + First body. + + """) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(store.selection.isEmpty) + #expect(store.transient.selectionHead == nil) + } +} + +// MARK: - The sort's write + +@MainActor +@Suite("BoardStore ▸ sortSelection") +struct SortWriteTests { + + @Test("A step rewrites the two cards that swapped and nothing else") + func stepWritesTheMinimum() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let untouchedFirst = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + let untouchedFourth = try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") + + store.select([card3], liveness: .live) + store.sortSelection(.up) + + #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card3, card2, card4]) + #expect( + try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == untouchedFirst, + "a card whose position did not change keeps its bytes — no stamp, no commit" + ) + #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card4)") == untouchedFourth) + #expect(store.selection.ids == [card3], "the ids all survive, so the selection is left alone") + #expect(store.banners.oneShots.isEmpty) + } + + @Test("A gather collects the block behind its first card, on disk") + func gatherWrites() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card2, card4], liveness: .live) + store.sortSelection(.up) + + #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2, card4, card3]) + } + + @Test("A step down moves the block past its following sibling") + func stepDownWrites() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.select([card1, card2], liveness: .live) + store.sortSelection(.down) + + #expect(try cardOrder(Ident.lane1, in: fixture) == [card3, card1, card2, card4]) + } + + @Test("Duplicate ranks are compacted first, because a permutation cannot outrank a name tie-break") + func duplicateOrdersRenumberFirst() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + // Two cards sharing one rank: display order falls to the folder-name tie-break + // (`Ranks.isOrderedForDisplay`), which card1's `5555…` wins over card2's `6666…`. + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1024", title: "Second")) + let store = try BoardStore(rootURL: fixture.root) + + #expect(try cardOrder(Ident.lane1, in: fixture) == [card1, card2]) + + store.select([card2], liveness: .live) + store.sortSelection(.up) + + #expect(try cardOrder(Ident.lane1, in: fixture) == [card2, card1]) + #expect(store.banners.oneShots.isEmpty) + } + + @Test("The plan refuses every case the design calls inert") + func planRefusals() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(store.sortPlan(.up) == nil, "nothing selected") + + store.select([lane1], liveness: .live) + #expect(store.sortPlan(.up) == nil, "a lane selection — ⌥⌘↑/⌥⌘↓ are inert on lanes") + + store.select([card2, card5], liveness: .live) + #expect(store.sortPlan(.up) == nil, "a card selection spanning lanes — cards never change lanes by ⌘-arrow") + + store.select([card1], liveness: .trashed) + #expect(store.sortPlan(.up) == nil, "a tombstoned selection") + + store.select([card1], liveness: .live) + #expect(store.sortPlan(.up) == nil, "already at the top") + #expect(store.sortPlan(.down) != nil, "but the other direction is live") + } +} + +// MARK: - The lane move's index convention + +@MainActor +@Suite("BoardStore ▸ moveLane's one-slot convention") +struct MoveLaneConventionTests { + + /// The index `MoveLaneCommands` passes: the lane's display position among the live lanes, plus + /// or minus one. `moveLane` counts that position **with the moved lane already removed**, which + /// is exactly what makes `from ± 1` one slot — and is easy enough to get backwards that it is + /// pinned here rather than left to the drag path's coverage. + @Test("from − 1 moves one slot left, from + 1 moves one slot right") + func oneSlotEachWay() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + #expect(lanes == [lane1, lane2, lane3]) + let from = try #require(lanes.firstIndex(of: lane2)) + + store.moveLane(lane2, toIndex: from - 1) + #expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3]) + } + + @Test("A step right hops exactly one lane, never to the end") + func stepRightHopsOne() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + let lanes = SelectionGrammar.liveLanes(in: store.snapshot) + let from = try #require(lanes.firstIndex(of: lane1)) + + store.moveLane(lane1, toIndex: from + 1) + #expect(try load(fixture).lanes.map(\.id) == [lane2, lane1, lane3]) + } +} diff --git a/README.md b/README.md index 340e517..88569c5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. +- **The keyboard** — the board is fully operable without the mouse, and every board function has a menu item. Arrows walk to the nearest card in the direction — across a lane's interior masonry columns, across lanes, and into and out of the shown trash — with ⇧-arrows extending the range from the anchor and stopping dead at the liveness and card/lane-entry boundaries rather than silently reaching past them. ⌥-arrows jump: ⌥↑/⌥↓ to the lane's first/last card, ⌥←/⌥→ to the first/last lane (⌥→ reaching the trash when it's shown), and a second ⌥↑ on a lane's first card escalates into selecting the lane itself — the one keyboard entry to lane selection, from which ←/→ move between lanes, ↓ descends back into the cards, and an empty selection seeds at the first lane's first card so an arrow from nothing always means the same thing. The selection scrolls itself into view. The Board menu carries the rest: Open Card (⌘↩) — the one command that stays live mid-edit, committing the title and opening the window — Move Up/Move Down (⌥⌘↑/⌥⌘↓), which sort within a lane in logical order and gather a scattered selection into a block behind its first card on the first press, and Move Left/Move Right (⌘←/⌘→), which slide a selected lane one slot and never into the trash. Deleting picks the successor sibling Finder-style, so repeated ⌫ walks down a lane; an external deletion deliberately doesn't. + - **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched. - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.