Files
lanework/Kanban/KanbanApp.swift
T
rzen 7651e40318 Print boards and cards with configurable components and named print profiles
⌘P had no story: KanbanApp removed the platform's Print row outright on
11-command-nexus.md's "No Print story in v1 (⌘P unused)" line. That line
retires. File ▸ Print… now prints the board in front — lanes left to right,
each lane's cards top to bottom, as a linear document rather than a picture
of the strip — or, from a card window, that card. The trash is unreachable
by construction: it is a sibling container of `lanes`, not a lane.

The rules live in a pure layer nothing AppKit can reach. `PrintOptions` is
one Codable value carrying the printing card's five bullets — which
components (title, icon+labels line, rendered body, comments off by default
with either reading order), page breaks, one base face and size every other
size derives from, and a toggleable running head and foot. `PrintSource` is
what is being printed, frozen at ⌘P so the panel's repeated relayouts and a
board reloading underneath cannot disagree. `PrintDocumentBuilder` turns the
pair into a block list, which is where every decision a rendered page hides
becomes something a test can hold: component order, comment ordering, and
page-break markers that are markers rather than whitespace. Empty is empty
all the way up — a card with nothing to print consumes no page break, and a
lane whose cards all dropped out takes its heading with it.

A page break is a pagination fact, not a spacing one. TextKit has no
page-break character, so `PrintDocumentView` splits the document into
sections at its breaks and flows each into as many page-sized text
containers as it needs: a container boundary *is* a sheet boundary, at any
paper size with any margins. Bodies come from the app's one Markdown pass —
`BodyMarkup.parse` into `BodyMarkupRenderer` — re-faced run by run so the
chosen family reaches the text and fixed-pitch code keeps its own, and drawn
under a forced light appearance so the card window's dynamic label colours
do not print white.

Options ride in the print panel's own accessory rather than a pre-flight
sheet of ours, which buys the system's live preview of the real paginated
document; the preview refreshes through one KVO revision counter rather than
thirteen mirrored properties. Profiles persist app-side in UserDefaults,
never in board files — a print profile is how this user likes to read, not
what a board is (`BoardZoomStore`'s argument). A name is a profile's
identity, folded case-insensitively; "Last Used" is reserved in every
spelling, kept out of the stored list, and captured when an operation
actually ran, so a cancelled print rewrites nothing. Both decoders are
total: one unrecognized key must not cost a user every profile they saved.

DESIGN/11-command-nexus.md gains the Print row and loses the sentence
saying it would never have one.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 22:42:11 -04:00

342 lines
18 KiB
Swift

import AppKit
import IndieAbout
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 (see
/// `LaunchPlan`): 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
/// What this launch does: welcome, the registry's restoration pass, or the accessibility audit
/// suite's fixture board (`LaunchPlan`, `UITestLaunch`).
private let launchPlan: LaunchPlan
init() {
// **AppKit window restoration is fully disowned** — restore-at-launch is the registry's job
// (02-architecture.md § Launch and window lifecycle), every scene below declares
// `.restorationBehavior(.disabled)`, and left alive the machinery is actively harmful: AppKit
// counts its saved state (even a windowless one) as "a restored session", and SwiftUI then
// treats every `defaultLaunchBehavior` as moot — the app launches with no windows at all and
// no way to get one, since `windowOpener` is captured by the first scene that appears
// (observed on macOS 26, 2026-07-29; `-ApplePersistenceIgnoreState YES` on the command line
// proved the mechanism). Registered here because `App.init` runs before `NSApplicationMain`,
// which is what makes a registration-domain default early enough for AppKit's read.
UserDefaults.standard.register(defaults: ["ApplePersistenceIgnoreState": true])
// Read first, because it decides *which app-side state the model is built over* — a fixture
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
// the app's ordinary Application Support home.
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
if isUITestFixtureLaunch {
UITestLaunch.prepareScratchDirectory()
}
let model = AppModel(
registryStorageURL: isUITestFixtureLaunch
? UITestLaunch.registryStorageURL
: BoardRegistry.defaultStorageURL,
clipboardStagingRoot: isUITestFixtureLaunch
? UITestLaunch.clipboardStagingRoot
: ClipboardStore.defaultStagingRoot
)
_appModel = State(initialValue: model)
launchPlan = LaunchPlan.decide(
isUITestFixtureLaunch: isUITestFixtureLaunch,
restorePreference: AppPreferences.restoreOpenBoardsAtLaunch,
hasRestorables: !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)
}
// **Never presented by the system** — the restore bootstrap below opens welcome through
// `AppModel.showWelcome()` when the launch pass ends with nothing else on screen. `.automatic`
// was tried here (conditioned on the plan) and macOS 26 answered it by presenting *no scene at
// all*: not welcome, not even a `.presented` bootstrap — a windowless launch with no way back,
// since `windowOpener` is captured by the first scene that appears. One presenter, one rule.
.defaultLaunchBehavior(.suppressed)
.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(plan: launchPlan)
.environment(appModel)
.captureWindowActions(into: appModel)
}
// **Presented at every launch, whatever the plan** — the bootstrap is the app's one reliable
// way to put a window on screen. Welcome's `.automatic` above is a request the system is free
// to decline, and on macOS 26 it does: a launch with nothing to restore presented *no* scene
// at all, which left `windowOpener` uncaptured and the app a windowless shell no menu action
// could revive (observed 2026-07-29). The pass itself
// still dispatches on the plan — a `.welcome` launch restores nothing and shows welcome —
// and this window stays invisible and dismisses itself either way.
.defaultLaunchBehavior(.presented)
.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)
// The **first** card window's size, and only that one: every later window opens at the
// last-used size or at its card's remembered frame, both applied by the host as the window
// attaches (05-card-window.md ▸ Window). Derived from font metrics like every other
// measurement in that window rather than written down in points.
.defaultSize(CardWindowMetrics.defaultSize(bodyPointSize: CardWindowMetrics.bodyPointSize))
Settings {
SettingsView()
.environment(appModel)
.captureWindowActions(into: appModel)
}
}
/// Every menu command 11-command-nexus.md inventories, at full row parity: built and validated
/// where the window behind it already exists, present-but-`FutureCommand`-disabled where it
/// doesn't (`FutureCommands.swift`).
///
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
/// mechanism — `NSUserKeyEquivalents` under the hood — 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, unique across the whole menu bar, and changing one
/// silently breaks every remap of it. Nothing below builds that mechanism — a stable title is the
/// whole of the contract, and the system supplies the rest.
@CommandsBuilder
private var menuCommands: some Commands {
// The About window (11-command-nexus.md files About under the app menu's standard
// furniture): icon, version/build/date from the Info.plist that `update_build_info.sh`
// stamped at build time — never a hardcoded string — with the version line opening the
// bundled changelog. `AboutBox` names no edition (12-editions.md ▸ PIVOT 2026-08-08) —
// there is one version of this app, so there is one box.
IndieAboutCommand(configuration: AboutBox.configuration)
// 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, Reveal in Finder, Add
// Attachment…, 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. Close is the system's own item — no row of ours to add.
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)
SaveAsTemplateCommand(appModel: appModel)
RevealInFinderCommand()
AddAttachmentCommand()
AddCommentCommand()
Divider()
TrashCommands()
}
// Edit ▸ Undo/Redo, replacing the system's own pair — **the command surface is the app's**
// (13-native-undo.md ▸ Rules, re-ruled 2026-08-08): the platform's nil-target rows resolve
// through `NSWindow.undoManager`, which a SwiftUI window latches empty before any delegate of
// ours can vend the board's, so the rows below read the focused session's stack themselves
// (`UndoCommands.swift`).
UndoRedoCommands()
// The Edit menu: Find (⌘F), then its card-window stepping twins, placed after the standard
// Cut/Copy/Paste/Select All group, which is where macOS puts Find. The clipboard items above
// them stay the system's, answered by the board as a responder (`ClipboardCommands.swift`) —
// a second item sharing one of those titles is what titles-are-API forbids. Undo and Redo
// were the system's too until the latch (the group just above).
CommandGroup(after: .pasteboard) {
FindCommand()
FindSteppingCommands()
}
// The system's own toolbar rows — Show/Hide Toolbar and **Customize Toolbar…**, which
// 11-command-nexus.md files under Standard macOS furniture ("Customize Toolbar… per system
// convention (03)"). They are the platform's, spelled by the platform: both are nil-target
// AppKit actions the key window's toolbar answers, so they validate per window (disabled on
// welcome, live on the board and card windows) with nothing of ours in between. The
// right-click ▸ Customize Toolbar… path 03 names is AppKit's too, and needs no row at all.
ToolbarCommands()
// 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. Three dividers split it
// by scope, which is the only grouping the inventory implies: the board's toggle, then the
// board's zoom ladder, then the card window's view-state rows, then the app-wide appearance
// override — last, because unlike everything above it, it needs no window in front at all.
//
// "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window
// menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a
// second one would collide (the rule this file's own header states).
CommandGroup(after: .toolbar) {
ShowTrashCommand()
Divider()
ZoomCommands(appModel: appModel)
Divider()
CardViewCommands()
Divider()
AppearanceCommands(appModel: appModel)
}
// 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.
//
// **Board Settings… came out 2026-08-07** with the sheet it opened (03 ▸ Board settings
// sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured
// in its popover, whose keyboard door is File ▸ Board Info ⌘I, so this menu's last divider
// went with the row.
//
// **The Board ▸ Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git
// itself (strategy/01-git-excision.md): the width pair is now the menu's last row.
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()
}
}
// File ▸ Print… (⌘P) — **the app's own row**, replacing the system's nil-target one
// (11-command-nexus.md's Print row; the "No Print story in v1 (⌘P unused)" line it retired).
//
// `replacing: .printItem` rather than an addition, for the same reason Undo/Redo replace the
// platform's pair: the standard rows are nil-target actions resolved through the responder
// chain, and this app's print target is the *frontmatter-shaped document behind the focused
// window*, which no responder vends. Two items sharing the title "Print…" is also exactly what
// titles-are-API forbids.
//
// Page Setup… stays absent with it: the paper questions are answered in the print panel's own
// page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one
// margin.
CommandGroup(replacing: .printItem) {
PrintCommand(appModel: appModel)
}
// Help carries "the one line teaching the System Settings remap path" (11-command-nexus.md ▸
// Standard macOS furniture) — this app's whole Help menu, since there is no other content to
// give it. The pane identifier is the modern System Settings extension id, not the legacy
// `com.apple.preference.keyboard` prefpane path: verified on macOS 26 by observing
// `KeyboardSettings.appex` (service `com.apple.Keyboard-Settings.extension`) launch in
// response to this exact URL. `?Shortcuts` is as deep as a URL reaches; **App Shortcuts**
// itself is one more click, the sidebar row inside that pane.
CommandGroup(after: .help) {
Button("Customize Keyboard Shortcuts…") {
guard let url = URL(
string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension?Shortcuts"
) else { return }
NSWorkspace.shared.open(url)
}
}
}
}