Files
lanework/Kanban/KanbanApp.swift
T
rzen 4035ba7986 Implement the keyboard grammar and full command map
The board's fixed grammar keys and the menu-backed chords of
04-interactions.md § Keyboard, per the Command Nexus inventory:

- Spatial arrow navigation (NavigationMath.nearest over the marquee
  registry's frames — one geometry source), walking across interior
  masonry columns, lanes, and into the shown trash; ⇧-arrows extend via
  the same range function as ⇧-click and go inert at the liveness and
  kind boundaries; ⌥-jumps with the ⌥↑ lane-domain escalation and ↓
  descent; the empty selection seeds at the first lane's first card;
  selection scrolls into view.
- selectionHead — the navigation cursor beside the anchor, set by every
  click, moved by every arrow, dropped by the reload vanish rule.
- Board ▸ Open Card ⌘↩ (the one command enabled mid-edit: commits the
  placeholder or rename and opens), Move Up/Move Down ⌥⌘↑/⌥⌘↓
  (within-lane sort, gather-then-step, rank-permuting writes in one
  bracket), Move Left/Move Right ⌘←/⌘→ (sole lane, one slot, never the
  trash) — all validating and acting off one shared answer.
- Delete now selects the Finder-style successor sibling from the
  pre-write snapshot, so repeated ⌫ walks down a lane; external
  vanishing still only shrinks the selection.
- handleReturn rejects modified Returns; the trash column renders
  eagerly so every row stays registered for navigation and the marquee.

686 unit tests (27 new).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 19:32:30 -04:00

197 lines
8.1 KiB
Swift

import SwiftUI
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
///
/// ### Four scenes, and why each is the kind it is
///
/// - **Welcome** is a `Window`: there is one of it, ever, and `openWindow(id:)` focuses the existing
/// one rather than making a second.
/// - **The restore bootstrap** is a `Window` too, and a deliberate oddity — see
/// `RestoreBootstrapView` for why launch-time work has to wear a window at all.
/// - **Boards** and **cards** are `WindowGroup(for:)`s, because their identity is a *value*: opening
/// with a ref that already has a window focuses it, which is how "one board window per root" and
/// "at most one card window per card (reopen focuses)" are enforced by the scene rather than by
/// bookkeeping.
///
/// ### Restoration is the registry's, not the system's
///
/// Both groups declare `.restorationBehavior(.disabled)`. The app already knows which boards were
/// open — the registry's open-now flags, which survive a crash and reopen in `lastOpened` order —
/// and letting AppKit *also* restore windows would produce duplicates, and worse, card windows
/// restored behind boards that never opened. One mechanism, and it is the one that can explain
/// itself when a board has moved or gone.
///
/// ### Which window appears at launch
///
/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived: the
/// preference is read before any scene exists, and `restorables()` costs a bookmark resolution per
/// known board — a computed property here would pay that on every scene-graph evaluation.
@main
struct KanbanApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@State private var appModel: AppModel
/// Whether this launch restores boards: the preference is on **and** there is something flagged
/// to restore. Welcome "appears only when nothing restores".
private let shouldRestoreAtLaunch: Bool
init() {
let model = AppModel()
_appModel = State(initialValue: model)
shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch
&& !model.boardRegistry.restorables().isEmpty
// The delegate is constructed by the adaptor before this runs, so this is the one place the
// app's model and its AppKit half meet.
appDelegate.appModel = model
}
var body: some Scene {
Window("Welcome to Lanework", id: WindowID.welcome) {
WelcomeView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
.restorationBehavior(.disabled)
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
// *minimum* and the recents list is meant to take whatever space the user gives it; the
// hidden title bar is why `WelcomeView` carries a `WindowDragGesture`.
.windowResizability(.contentMinSize)
.defaultSize(width: 820, height: 500)
.windowStyle(.hiddenTitleBar)
// Its automatic Window-menu item is replaced by the explicit command below, so the title is
// the one 11-command-nexus.md names rather than whatever the scene happens to be called.
.commandsRemoved()
// The template chooser (09-templates.md), reached from File ▸ New Board… ⌥⌘N and from
// welcome's own button. A `Window` for welcome's reason — there is one of it, ever — and
// suppressed at launch, since ⌥⌘N is the only thing that ever asks for it.
Window("New Board", id: WindowID.templateChooser) {
TemplateChooserView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(.suppressed)
.restorationBehavior(.disabled)
.windowResizability(.contentSize)
.commandsRemoved()
Window("", id: WindowID.restoreBootstrap) {
RestoreBootstrapView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .presented : .suppressed)
.restorationBehavior(.disabled)
.windowStyle(.plain)
.defaultSize(width: 1, height: 1)
.commandsRemoved()
WindowGroup(id: WindowID.board, for: BoardWindowRef.self) { $ref in
if let ref {
BoardWindowHost(ref: ref)
.environment(appModel)
.captureWindowActions(into: appModel)
}
}
.restorationBehavior(.disabled)
.defaultLaunchBehavior(.suppressed)
.commands { menuCommands }
WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in
if let ref {
CardWindowHost(ref: ref)
.environment(appModel)
.captureWindowActions(into: appModel)
}
}
.restorationBehavior(.disabled)
.defaultLaunchBehavior(.suppressed)
Settings {
SettingsView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
}
/// The menu items the app has built so far.
///
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
/// mechanism remaps menu items *by title*, so these strings are the keys a user's custom binding
/// is stored under. They are spelled exactly as 11-command-nexus.md inventories them, and
/// changing one silently breaks every remap of it.
@CommandsBuilder
private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
// Open…, Open Recent ▸, Board Info, Duplicate, (Save as Template, still owed — 09),
// Reveal in Finder, then the trash trio and Empty Trash…. The board-scoped ones validate
// against the frontmost board through the focus system, so each is simply absent-of-effect
// when no board is in front; New Board… and Open Recent are available everywhere, including
// with no window at all.
CommandGroup(after: .newItem) {
BoardCreationCommands()
NewBoardCommand(appModel: appModel)
Divider()
Button("Open…") {
appModel.presentOpenPanel()
}
.keyboardShortcut("o", modifiers: .command)
OpenRecentMenu(appModel: appModel)
Divider()
BoardInfoCommand()
Divider()
DuplicateBoardCommand(appModel: appModel)
RevealInFinderCommand()
Divider()
TrashCommands()
}
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
// items live in — which is where 11-command-nexus.md files Show Trash, alongside the card
// window's Edit Body / Raw Source / History still to come.
CommandGroup(after: .toolbar) {
ShowTrashCommand()
}
// 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()
}
CommandGroup(after: .windowList) {
// No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps
// like any other item.
Button("Welcome to Lanework") {
appModel.showWelcome()
}
}
}
}