Files
lanework/Kanban/UI/Board/BoardCommands.swift
T
rzen 72ca22251b A card's context menu finds its four groups — style, clipboard, navigation, and the trash
CardFaceView.boardMenu/boardActions restructured to the owner's shape (card
fe66c461): Open/Copy Link/Rename/Style▸(Symbol,Color), a divider, then
Copy/Cut/Paste/Paste Special▸(Paste Image into Card), a divider, then
Navigation▸(Move Left,Move Right), a divider, then Send to Trash. Every row
routes through existing machinery — no new commands.

ClipboardStore gains copy(from:targeting:)/cut(from:targeting:) and their
canCopy/canCut twins, so Copy and Cut can widen to the clicked card exactly
as Delete and Style already do ("right-clicking something outside the
selection acts on what was clicked"), without disturbing the Edit-menu path.

LaneMoveTarget.destination is extracted out of MoveLaneCommands so the
card menu's Navigation rows validate against the identical sole-live-lane
predicate as Board ▸ Move Left/Right. Since a card id can never itself
satisfy that predicate, the two rows are wired to the real store call but
unconditionally disabled — reading the live selection per card face would
reproduce the O(board) render regression isSelected/selectedCount exist to
prevent (contextMenu's builder is not lazy).

Style ▸ Symbol and ▸ Color both open the one existing style popover — no
per-section pre-focus (StyleEditorSession has no such concept, and
StyleEditorView internals are out of scope while another pass redesigns
the pickers). Paste and Paste Image into Card reduce their .disabled
checks to selection/snapshot-free forms, proven safe by construction (a
rendered card face already guarantees a live lane / a live board card).

Journaled on the card: Copy Link kept (shipped same day, not in the
owner's list), "Delete" relabeled "Send to Trash" (board-side move, not
the permanent trash delete), quick-style recents row dropped from this
menu, Navigation's always-disabled rows, and Paste not retargeting to the
clicked card — all flagged needs owner review. DESIGN/11-command-nexus.md's
Card row is owed a rewrite, left for the main session.

Tests: LaneMoveTarget.destination (new), targeted copy/cut (new), plus
existing ClipboardStore/PasteTarget/MoveLane/CopyLink/Trash-menu/PasteImage/
PasteFile/Style/CaretChord/render-performance/equatable-gate suites —
156 tests, all passing.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 09:44:30 -04:00

628 lines
30 KiB
Swift

import Observation
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: - 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: (@MainActor (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.
///
/// - **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 — `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.
extension BoardStore {
var acceptsBoardMutations: Bool {
!isReadOnly && !isEditingInline
}
}
/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled):
///
/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation
/// > whenever *any* text control has keyboard focus — inline title editors, the board search field,
/// > board-popover fields (rename, git identity, remote), and card-window fields — because an
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
/// > standard line-start/end caret chords.
///
/// Four text surfaces, answered three ways, and only two of them are here:
///
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
/// broader lockdown that already covers these two items.
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
/// move belongs under it. That clause covered **every** configuration field the app had since
/// 2026-08-07: the 2026-07-31 split moved most of them to a board settings sheet, whose flag this
/// function read as a third disjunct, and the reversal brought them back under the popover's own
/// open-at-all rule — branch creation, commit identity, and pro-m2's remote URL and credentials
/// among them, all in the popover's Git tab at the time. The Git tab itself retired with
/// app-managed git (`strategy/01-git-excision.md`, 2026-08-08); the popover's open-at-all rule
/// now guards only `BoardInfoTabView`'s ordinary fields.
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
/// are already scopeless there.
///
/// Stated once because the two command groups must not drift: a rule with two implementations is a
/// rule with two chances to forget a surface.
@MainActor
func caretChordsYield(
boardInfo: BoardInfoPresentation?,
search: BoardSearchPresentation?
) -> Bool {
boardInfo?.isPresented == true || search?.isFocused == true
}
// 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 || store.openCardTarget != nil
}
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 }) {
store.select([lane], in: .board)
}
if let created { open(created) }
return
}
if let editor = store.transient.renameEditor {
let target = editor.targetID
let isCard = BoardStore.boardItem(target, in: store.snapshot)?.cardID != nil
store.commitRename()
if isCard { open(target) }
return
}
if let card = store.openCardTarget { open(card) }
}
}
// MARK: - The three edit-shaped targets, as pure predicates
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md ▸ The trash: "Everything
/// edit-shaped is disabled on trash selections — Open Card, Rename, Style…"), and each of the three
/// answers that with one expression used for both its `disabled` state and its action — the
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
///
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
/// without a menu (`SelectionGrammarTests`) — the same reason `TrashModel`'s validation is a pure
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
extension BoardStore {
/// Board ▸ Open Card's target: the sole selected **board card**, or `nil`. A lane, a
/// multi-selection and a trash selection all answer `nil` — a card window is tied to one card,
/// and trash cards don't open ("double-click stops at selection; move it out first" — 03 §
/// Trash).
///
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
var openCardTarget: ItemID? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return id
}
/// Board ▸ Rename's target: the sole selected board item, card or lane, with the title to seed
/// the editor with — or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
/// container answers directly.
var renameTarget: (id: ItemID, title: String?)? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
let item = Self.boardItem(id, in: snapshot)
else { return nil }
return (id: id, title: item.title)
}
/// Board ▸ Style…'s target: the selected board items, or **the board itself** when nothing is
/// selected — "Board window: selected cards or lane; nothing selected = the board".
///
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
/// the board because the user had a trashed card selected would be the silent retarget 03
/// forbids.
var boardStyleTarget: StyleTarget? {
guard acceptsBoardMutations else { return nil }
guard !selection.isEmpty else { return .board }
guard selection.container == .board 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: snapshot).ids
return live.isEmpty ? nil : .items(live)
}
}
// 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 trash 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
/// **The pure predicate behind Board ▸ Move Left / Move Right** — extracted so the menu-bar row
/// (`MoveLaneCommands`) and the card context menu's Navigation submenu (`CardFaceView`, 2026-08-09 ▸
/// "redesign context menu for cards") validate off one definition rather than two that could drift.
///
/// "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 batches over a multi-lane selection; this rule'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 this
/// validates on exactly one selected live lane.
///
/// **A card id answers `nil` here, on purpose and unconditionally** — "a card id is in no lane
/// order, so this is also the 'not a lane' test". That is the whole of why the card context menu's
/// own Navigation rows are a structural mismatch rather than a card-scoped move: this predicate reads
/// the *board's live selection*, never the clicked card, so a card menu's Move Left/Right can only
/// ever be live when the selection elsewhere on the board happens to be a sole lane — see
/// `CardFaceView`'s own note on why it does not even attempt to read this per card.
///
/// **Never into the trash** costs nothing: the column is not in the 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.
enum LaneMoveTarget {
/// 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.
static func destination(
selection: ItemReferenceSet,
snapshot: BoardModel,
delta: Int
) -> (lane: ItemID, index: Int)? {
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first
else { return nil }
let lanes = SelectionGrammar.lanes(in: 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)
}
}
/// 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). The
/// validating predicate is `LaneMoveTarget.destination(selection:snapshot:delta:)`; this struct is the
/// menu-bar row around it.
///
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the
/// width pair below.
struct MoveLaneCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View {
Button("Move Left") {
move(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: .command)
.disabled(yieldsCaretChords || destination(-1) == nil)
Button("Move Right") {
move(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: .command)
.disabled(yieldsCaretChords || destination(1) == nil)
}
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, search: search)
}
private func destination(_ delta: Int) -> (lane: ItemID, index: Int)? {
guard let store, store.acceptsBoardMutations else { return nil }
return LaneMoveTarget.destination(selection: store.selection, snapshot: store.snapshot, delta: delta)
}
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.
///
/// **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") {
store?.beginNewCard()
}
.keyboardShortcut("n", modifiers: .command)
.disabled(store?.newCardTarget == nil)
Button("New Lane") {
store?.createLane()
}
.keyboardShortcut("n", modifiers: [.shift, .command])
.disabled(store?.acceptsBoardMutations != true)
}
}
extension BoardStore {
/// Where ⌘N would file a card, or `nil` when it cannot — a board that refuses writes, an inline
/// editor holding the keyboard, or a board with no lanes.
///
/// On the store rather than private to the menu row because **the toolbar's New Card item is the
/// same command** (03-board-ui.md ▸ Toolbar: toolbar items mirror menu commands), and a second
/// derivation of this rule would be a second chance to disagree with it — the same reason the row
/// itself uses one answer for both its action and its validation.
var newCardTarget: NewCardTarget.Resolution? {
guard acceptsBoardMutations else { return nil }
return NewCardTarget.resolve(
selection: selection,
lastActiveLaneID: transient.lastActiveLaneID,
snapshot: snapshot
)
}
/// ⌘N's action — opening the new-card placeholder wherever `newCardTarget` says. Shared with the
/// toolbar item that mirrors the row.
func beginNewCard() {
guard let target = newCardTarget else { return }
transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
}
}
// MARK: - Board Info
/// File ▸ Board Info (⌘I) — the board popover's keyboard path (11-command-nexus.md; class **C**,
/// whose "keyboard path is reachability (Board Info ⌘I + Tab-reachable controls), not bindings").
///
/// **It toggles**, because the popover's other entry point is a disclosure widget and a shortcut
/// that could only ever open would leave the surface with no keyboard way out.
///
/// Two focused values, both required: the store is what makes this a *board window* item (the
/// scope 11 gives the row), and the presentation is the window's own popover flag — see
/// `BoardInfoPresentation` for why the flag is per window rather than per board.
///
/// **Validation is scope and nothing else.** Neither the read-only lock nor the focused-editor rule
/// closes it, unlike every mutating item above: the popover is *configuration* (04-interactions.md
/// ▸ The map's carve-out), a locked board is exactly when a user wants to read its title and
/// styling, and the controls inside disable themselves.
struct BoardInfoCommand: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var presentation
var body: some View {
Button("Board Info") {
presentation?.toggle()
}
.keyboardShortcut("i", modifiers: .command)
.disabled(store == nil || presentation == nil)
}
}
// MARK: - Copy Link
extension BoardStore {
/// Board ▸ Copy Link's target: the sole selected **live board card**'s folder URL, or `nil` — "a
/// link is singular" (design ruling 2026-08-09, card 737a949f "Add an option to card context menu
/// to copy a link to the card folder"), the same board-card-only shape `openCardTarget` answers
/// for its own reason: a lane, a multi-selection and a trash selection all disable it.
///
/// **Additionally gated on `acceptsBoardMutations`**, unlike `openCardTarget` — the ruling asks
/// for this in as many words ("disabled … wherever edit-shaped actions already disable"), even
/// though writing a link to the pasteboard changes nothing on disk. Worth flagging rather than
/// silently matching: Copy Link could have stayed live under the lock the way Reveal in Finder
/// does ("not edit-shaped … inspecting a folder before a purge is exactly the errand it exists
/// for" — `CardFaceView.trashMenu`'s doc), but the ruling states the gate explicitly, so it is
/// implemented as written rather than re-litigated here (the card's DECISIONS comment flags it).
var copyLinkTarget: URL? {
guard acceptsBoardMutations else { return nil }
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
Self.boardItem(id, in: snapshot)?.cardID != nil
else { return nil }
return ItemPath.resolve([id], in: .board, snapshot: snapshot).first?.folder(under: rootURL)
}
/// Board ▸ Copy Link's action, and the menu-bar row's one write. The context menu's own Copy Link
/// row (`CardFaceView.copyLink`) does not call this: a context menu names its target by where it
/// was invoked (`targetIDs`'s standing rule, shared with Style… and Delete), not by the live
/// selection this property reads.
func copyCardLink(to pasteboard: FolderLinkPasteboard = SystemFolderLinkPasteboard()) {
guard let folder = copyLinkTarget else { return }
pasteboard.write(link: folder)
}
}
/// Board ▸ Copy Link — no default chord (11-command-nexus.md; design ruling 2026-08-09, card
/// 737a949f). The menu-bar twin the ruling asks for ("follow the codebase's CURRENT command
/// conventions … if the every-function-a-menu-item contract still governs, add the item … in the
/// matching menu"): 11-command-nexus.md's contract is still in force, so this exists beside the card
/// context menu's own row (`CardFaceView.boardMenu`) rather than instead of it.
///
/// One answer (`copyLinkTarget`) for both the row's `disabled` state and its action, `BoardRenameCommand`'s
/// shape.
struct CopyLinkCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Copy Link") {
store?.copyCardLink()
}
.disabled(store?.copyLinkTarget == nil)
}
}
// 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-board-item rule — card or lane, either kind, exactly one. A
/// trash selection never enables it: "everything edit-shaped is disabled on trash selections"
/// (04 ▸ The trash), which `ItemReferenceSet`'s container answers directly.
struct BoardRenameCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Rename") {
guard let store, let target = store.renameTarget else { return }
store.transient.beginRename(of: target.id, currentTitle: target.title)
}
.disabled(store?.renameTarget == nil)
}
}
// 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 trash selection disables it rather than falling through to the board**
/// (`BoardStore.boardStyleTarget`, where both halves live).
struct BoardStyleCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Button("Style…") {
guard let store, let target = store.boardStyleTarget else { return }
store.transient.beginStyleEditor(for: target)
}
.keyboardShortcut("s", modifiers: [.option, .command])
.disabled(store?.boardStyleTarget == nil)
}
}
// 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.
///
/// **They batch over a multi-lane selection** (03-board-ui.md § Lane, settled — the styling
/// precedent): each selected lane steps one unit, one gesture, one commit
/// (`BoardStore.stepLaneWidths`); the context-menu stepper stays single-lane by nature. A card
/// selection, a trash-side selection and an empty one all disable them. Decrease additionally
/// disables when **every** selected lane is at the one-unit floor — a mixed batch stays live, its
/// floor members simply holding (the style batch's silent skip).
///
/// **⌥⌘←/⌥⌘→ yield to any focused text control** (04-interactions.md ▸ Grammar's caret-chords
/// rule) — see `MoveLaneCommands`, whose ⌘←/⌘→ carry the same obligation and whose doc comment
/// carries the mechanism.
struct LaneWidthCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View {
Button("Increase Lane Width") {
step(by: 1)
}
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
Button("Decrease Lane Width") {
step(by: -1)
}
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
.disabled(yieldsCaretChords || !canDecrease)
}
private var yieldsCaretChords: Bool {
caretChordsYield(boardInfo: boardInfo, search: search)
}
/// The selected live lanes, in snapshot order — the batch, and the 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 selectedLanes: [Lane] {
guard let store, store.acceptsBoardMutations else { return [] }
let selection = store.selection
guard selection.container == .board, !selection.isEmpty else { return [] }
return store.snapshot.lanes.filter { selection.ids.contains($0.id) }
}
/// `width` is ≥ 1 (03-board-ui.md § Lane), and an item whose only outcome is a no-op reads
/// better disabled than dead — which for a batch means *some* member must have room to shrink.
private var canDecrease: Bool {
selectedLanes.contains { LaneLayoutMath.displayUnits(of: $0) > 1 }
}
private func step(by delta: Int) {
guard let store else { return }
let lanes = selectedLanes
guard !lanes.isEmpty else { return }
store.stepLaneWidths(Set(lanes.map(\.id)), by: delta)
}
}