Files
lanework/Kanban/KanbanApp.swift
T
rzen ff3ba298f0 Build the full-visibility lane layout
The lane strip replaces the placeholder board: window width divides
across the lanes' width units (no horizontal scroll, no minimum width,
degenerate compression accepted), a lane of n units flowing its cards
into n round-robin masonry columns via a measurement-cached Layout.
Width has two deliberately opposite controls, both landing here: the
right-edge drag (ported verbatim from the pathfinder's ColumnResize)
freezes the 1x standard at drag start, snaps between integer widths
with the asymmetric shadow-leads tick and 10pt re-entry, grows the
window one standard width per snap so siblings keep their exact
pixels, and rubber-bands at the screen's visible frame — uncapped
otherwise; the Increase/Decrease Lane Width items (new Board menu,
Cmd-Opt-arrows) are the stepper's keyboard face and re-divide the
existing window width instead, never touching the window. Width
changes write through the new .resize WriteOperation ("Couldn't
resize…" in the banner vocabulary, which grows with the surfaces by
design); malformed width values render as one unit and stay untouched
on disk. 27 new tests port the pathfinder's resize-math suite onto
the uncapped range and pin the write path's fidelity.

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

133 lines
5.5 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)
.windowResizability(.contentSize)
// 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()
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 {
CommandGroup(after: .newItem) {
Button("Open…") {
appModel.presentOpenPanel()
}
.keyboardShortcut("o", modifiers: .command)
}
// The Board menu (11-command-nexus.md). Its items act on the frontmost board window, which
// they reach through the focus system rather than through the app model — see
// `LaneWidthCommands`, which also owns their validation.
CommandMenu("Board") {
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()
}
}
}
}