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 Edit menu's one row of ours: Find (⌘F), placed after the standard Cut/Copy/Paste/ // Select All group, which is where macOS puts Find. Undo/Redo and the clipboard items are // the system's and the board answers them as a responder (`ClipboardCommands.swift`) — a // second item sharing one of those titles is what titles-are-API forbids. // // m6-card-window: Find Next / Find Previous (⌘G/⇧⌘G) join here, scoped to the card window's // find bar and "disabled in the board window — board search is a live filter, not a cursor" // (11-command-nexus.md). They wait for the window that owns them rather than shipping as two // permanently disabled rows. CommandGroup(after: .pasteboard) { FindCommand() } // 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() } } } }