One style-editor component, anchor-agnostic: a background grid (None well plus the 12 palette colors) and a curated symbol grid (the pathfinder's five-dozen set, leading well removing the icon key for the level default), selection-aware across cards, lanes, and the board itself. Batch edits compute per-dimension state — uniform, mixed (no well selected), or an off-palette value labeled verbatim outside the grids — and choosing a well applies to the whole target set as one write bracket, skipping no-ops per field. The popover tracks its target set live per the freshly ratified rule: targets re-resolve by UUID on every reload, a vanished target leaves the set, an emptied set dismisses the editor, and nothing ever silently retargets to the board. Anchors landing now: Board > Style (Opt-Cmd-S) and the card/lane context menus, which also carry the quick-style recents row (app-wide, persisted, capped at six, None never recorded) and the lane's width control twinning the menu chords. The styling system's other two renders arrive with it: a lane's background paints the C7 top-edge band, the board's paints the window content background — malformed values paint nothing and stay byte-identical on disk. 31 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
219 lines
9.9 KiB
Swift
219 lines
9.9 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: - Style
|
|
|
|
/// Board ▸ Style… (⌥⌘S) — the style editor's menu-bar anchor (11-command-nexus.md;
|
|
/// 03-board-ui.md § Styling ▸ Controls).
|
|
///
|
|
/// **Selection-aware, with the board as the empty-selection case**: "Board window: selected cards or
|
|
/// lane; nothing selected = the board". The item does not present anything itself — it opens the
|
|
/// session (`TransientBoardState.beginStyleEditor`) and the board window's anchors decide which
|
|
/// surface hosts the popover, which is what keeps the presentation attached to what is being styled
|
|
/// rather than to the menu bar.
|
|
///
|
|
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
|
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
|
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
|
|
/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly
|
|
/// restyling the board because the user had a trashed card selected would be the silent retarget
|
|
/// 03 forbids.
|
|
struct BoardStyleCommand: View {
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
|
|
var body: some View {
|
|
Button("Style…") {
|
|
guard let store, let target = styleTarget else { return }
|
|
store.transient.beginStyleEditor(for: target)
|
|
}
|
|
.keyboardShortcut("s", modifiers: [.option, .command])
|
|
.disabled(styleTarget == nil)
|
|
}
|
|
|
|
private var styleTarget: StyleTarget? {
|
|
guard let store, store.acceptsBoardMutations else { return nil }
|
|
let selection = store.selection
|
|
guard !selection.isEmpty else { return .board }
|
|
guard selection.liveness == .live else { return nil }
|
|
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
|
// items that render — the same universe its own reload rule will hold it to.
|
|
let live = selection.resolved(against: store.snapshot).ids
|
|
return live.isEmpty ? nil : .items(live)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|