Files
lanework/Kanban/UI/Board/BoardCommands.swift
T
rzen b35566e0fe Build lane chrome — title bar, badge, inline rename
The lane title bar becomes real: leading SF Symbol (hand-written names
render leniently, unknown ones fall back to the level default), title
or secondary untitled placeholder, a quiet count badge that counts
exactly the cards the body renders (so the m5 search filter is
followed by construction), and a new-card button. The whole bar is
the reorder drag surface — no grip — with click-vs-movement splitting
select from drag; a pure proposal function maps the drag to an
insertion index and release commits through the Writer's same-parent
degenerate reorder, compacting and retrying when midpoint precision
runs out. Clicking never edits: inline rename is Return on the sole
selected card or Board > Rename for either kind, a third transient
editor beside the placeholder that tracks its target by UUID, commits
on focus loss, discards silently when the target vanishes, and
removes the title key on an empty commit. The new-card placeholder
renders at last — the settled Cmd-N target rule (pure, tested) files
it after the anchor card, at a selected lane's bottom, or into the
last-active lane; Return commits and re-selects the lane, Cmd-Return
also opens the card window, and a failed create discards the overlay.
New Card / New Lane / Rename land in the menus with focused-editor
and read-only validation; rename gets its own WriteOperation case in
the banner vocabulary. 59 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 08:52:24 -04:00

177 lines
7.8 KiB
Swift

import SwiftUI
// MARK: - The focused board
/// The frontmost board window's store, published into the focus system by `BoardWindowHost` so menu
/// items can act on "the board in front" without the app model keeping a which-window-is-key
/// register of its own.
///
/// `focusedSceneValue` rather than `focusedValue`: the value is the *window's*, not any particular
/// control's, so it stays available whatever inside the board has keyboard focus — which is what a
/// menu item validating against the selection needs.
struct FocusedBoardStoreKey: FocusedValueKey {
typealias Value = BoardStore
}
extension FocusedValues {
var boardStore: BoardStore? {
get { self[FocusedBoardStoreKey.self] }
set { self[FocusedBoardStoreKey.self] = newValue }
}
}
// MARK: - Shared validation
/// The two conditions **every** board-mutating menu item disables on, in one place.
///
/// - **The read-only lock** (02-architecture.md § The lock's scope): "every mutating command
/// disables via menu validation" across every window sharing the store. An item that is going to
/// be refused should not look available.
/// - **The focused-editor rule** (04-interactions.md ▸ Grammar, settled): "while an inline title
/// editor — rename or the new-card placeholder — is focused, board-scoped menu commands (Delete,
/// New Card, Paste, Move, Style, …) disable via menu validation" 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.
///
/// Stated once rather than repeated per item, because the interesting failure mode is an item that
/// quietly forgets half of it.
extension BoardStore {
var acceptsBoardMutations: Bool {
!isReadOnly && !isEditingInline
}
}
// MARK: - Creation items
/// File ▸ New Card (⌘N) and File ▸ New Lane (⇧⌘N) — 11-command-nexus.md's two creation rows.
///
/// **New Card resolves its target through `NewCardTarget`**, the ⌘N target rule as a pure function,
/// and uses the *same* answer for its `disabled` state as for its action: a `nil` resolution is the
/// zero-lane board, where "card creation and card paste have no target — New Card, Return-creation,
/// and Paste with a card payload disable via menu validation until a lane exists"
/// (04-interactions.md ▸ The map). Two derivations of that condition would be two chances to
/// disagree.
///
/// **New Lane is enabled whenever the board accepts writes.** It is the way *out* of a zero-lane
/// board — "New Lane (⇧⌘N) is one way in" — so it can have no selection precondition at all. The
/// lane it creates is untitled and no editor opens on it; see `BoardStore.createLane`.
struct BoardCreationCommands: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("New Card") {
guard let store, let target = newCardTarget else { return }
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
}
.keyboardShortcut("n", modifiers: .command)
.disabled(newCardTarget == nil)
Button("New Lane") {
store?.createLane()
}
.keyboardShortcut("n", modifiers: [.shift, .command])
.disabled(store?.acceptsBoardMutations != true)
}
/// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses
/// writes, an inline editor holding the keyboard, or a board with no lanes.
private var newCardTarget: NewCardTarget.Resolution? {
guard let store, store.acceptsBoardMutations else { return nil }
return NewCardTarget.resolve(
selection: store.selection,
lastActiveLaneID: store.transient.lastActiveLaneID,
snapshot: store.snapshot
)
}
}
// MARK: - Rename
/// Board ▸ Rename — no default chord, deliberately (11-command-nexus.md: "— (cards: Return in
/// place)"), and remappable like any other item.
///
/// It "exists for completeness and remapping" for cards, whose real path is Return, and it is a
/// **lane's only rename path**: Return on a lane creates a card, so without this item a lane could
/// never be renamed at all (04-interactions.md ▸ Selection).
///
/// Validation is the sole-selected-live-item rule — card or lane, either kind, exactly one. A
/// tombstoned selection never enables it: "everything edit-shaped is disabled on tombstoned
/// selections" (04 ▸ The trash), which `ItemReferenceSet`'s liveness side answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Rename") {
guard let store, let target = renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(renameTarget == nil)
}
private var renameTarget: (id: ItemID, title: String?)? {
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,
let item = BoardStore.liveItem(id, in: store.snapshot)
else { return nil }
return (id: id, title: item.title)
}
}
// MARK: - Lane width items
/// Increase / Decrease Lane Width — **the width stepper's keyboard face** (03-board-ui.md § Lane,
/// 11-command-nexus.md), and therefore the *re-divide* mechanism: each step re-divides the existing
/// window width across the new unit total, compressing the siblings. They never touch the window's
/// size — window-growing behaviour belongs to the right-edge drag alone — and they are uncapped, so
/// widths beyond what the screen can fit stay reachable here even though the drag hard-stops.
///
/// **Validation is the sole-selected-lane rule.** Both items are enabled only when the focused
/// board's selection resolves to exactly one live lane; a card selection, a multi-selection, a
/// trash-side selection and an empty one all disable them. Decrease additionally disables at one
/// unit, which is the floor.
struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Increase Lane Width") {
step(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
.disabled(selectedLane == nil)
Button("Decrease Lane Width") {
step(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
.disabled(!canDecrease)
}
/// The sole selected live lane, or `nil` — the whole of these items' validation.
///
/// The lock and the open-editor rule are folded in through `acceptsBoardMutations` rather than
/// left for the write to refuse: an item that is going to fail should not look available.
private var selectedLane: Lane? {
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 }
return store.snapshot.lanes.first { $0.id == id && !$0.isDeleted }
}
/// A one-unit lane cannot shrink: `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only
/// outcome is a no-op reads better disabled than dead.
private var canDecrease: Bool {
guard let lane = selectedLane else { return false }
return LaneLayoutMath.displayUnits(of: lane) > 1
}
private func step(by delta: Int) {
guard let store, let lane = selectedLane else { return }
store.setLaneWidth(lane.id, units: LaneLayoutMath.displayUnits(of: lane) + delta)
}
}