The window-title widget arrives as a leading titlebar accessory — a quiet chevron on board windows only, installed and removed by the window controller's own attach lifecycle — anchoring the one board-level surface as a transient popover (the board window deliberately grows no toolbar item for it). Inside: board rename editing frontmatter title only (the folder is never renamed; an empty commit removes the key and the window title falls back to the folder name), the embedded shared style editor permanently targeting the board, and the labeled Git section that this milestone only reserves — a mode-none explanation and a disabled stub where m7's add-git, branch, remote, and authentication controls land. Cmd-I (File > Board Info) toggles it per window through a focused scene value, kept apart from board-scoped transient state since a titlebar popover belongs to one window, not to the board. Escape reverts a dirty rename field and falls through to dismiss otherwise; a foreign rename resyncs the field only while unfocused. 12 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
249 lines
11 KiB
Swift
249 lines
11 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: - 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: - 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)
|
|
}
|
|
}
|