Files
lanework/Kanban/App/BoardWindowHost.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

226 lines
11 KiB
Swift

import AppKit
import SwiftUI
import os
// MARK: - BoardWindowHost
/// One board window: the thing that owns a board's session for as long as it is on screen
/// (02-architecture.md § Windows, § Launch and window lifecycle).
///
/// ### It is a lifecycle, not a layout
///
/// Almost everything here is about beginning and ending: acquiring the shared store, stamping the
/// registry, holding the board's security-scoped access, remembering the window's frame, and running
/// the close flush before any of it is let go. The board *itself* — the lane strip and everything in
/// it — is `BoardView`'s (03-board-ui.md); this file hands it the store and the window and stays out
/// of the way.
///
/// ### Failure opens welcome
///
/// A board that will not load has nothing to show, so its window never appears: the failure joins
/// `AppModel.launchFailures`, welcome comes up, and this window dismisses itself. That is the same
/// path a failed restoration takes — "welcome appears alongside whatever did restore, the failed
/// board's recents row carrying fail-fast's specifics" — with the row-level rendering still owed.
struct BoardWindowHost: View {
let ref: BoardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
/// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it
/// alive for exactly as long as this window exists.
@State private var windowController = HostedWindowController()
/// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
/// window controller's reason — one per window, living exactly as long as the window — which is
/// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the
/// menu item through the focus system, like the store.
@State private var boardInfo = BoardInfoPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: Delete Immediately and Empty Trash… are
/// menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations()
/// How Board ▸ Open Card reaches this window's card windows. `@State` for `boardInfo`'s reason,
/// and published the same way: a menu item has no window of its own, and only this view holds
/// the board half of a card window's `(board, card)` identity — see `CardOpener`.
@State private var cardOpener = CardOpener()
@State private var phase: Phase = .opening
private enum Phase {
case opening
case open(BoardStore)
/// The load failed; this window is on its way out and must not try again.
case failed
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "board-window")
var body: some View {
content
.frame(minWidth: 640, minHeight: 400)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
.task { await start() }
.onDisappear { endSessionIfStillOpen() }
}
@ViewBuilder
private var content: some View {
switch phase {
case .opening, .failed:
// Nothing to render and nothing worth animating: this window either becomes a board in a
// moment or dismisses itself.
Color.clear
case let .open(store):
VStack(spacing: 0) {
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
// The window is handed to the board as a closure, not a value: `WindowAccessor`
// attaches after this body first runs, and the lane-resize drag needs the *live*
// window to grow at its right edge (03-board-ui.md § Lane).
//
// `openCard` is the host's too, for a different reason — see the property below.
BoardView(
store: store,
window: { windowController.window },
confirmations: trashConfirmations,
openCard: openCard
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File ▸ Board Info toggles, and
// its purge-alert host, which the trash's two confirmed commands raise.
.focusedSceneValue(\.boardStore, store)
// The window's identity beside its store — File ▸ Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing.
.focusedSceneValue(\.boardWindowRef, ref)
.focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
// Board ▸ Open Card's second half — the same closure `BoardView` gets, so the menu item
// and the double-click open one window per card by construction.
.focusedSceneValue(\.cardOpener, cardOpener)
}
}
/// Opens a card's window. `openWindow(value:)` with a ref that already has a window focuses it,
/// so "at most one card window per card (reopen focuses)" needs no bookkeeping here
/// (02-architecture.md § Windows).
private var openCard: (ItemID) -> Void {
{ cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
}
private var windowTitle: String {
guard case let .open(store) = phase else { return "" }
return AppModel.displayName(of: store)
}
// MARK: - Opening
/// Acquires the board and starts its session, or fails it out to welcome.
///
/// The order is load-bearing. Security-scoped access is claimed **before** `acquire`, because
/// acquire's first act is a full tree walk and a sandboxed read outside the scope is exactly the
/// one that gets refused. `setOpenNow` comes **after** the record exists and after the window has
/// demonstrably opened — a flag set on a board that never appeared would hand the next launch a
/// restoration set describing a failure.
private func start() async {
guard case .opening = phase else { return }
// Claimed even on the failure path: an unclaimed stash is a scope nobody balances.
let access = appModel.claimPendingAccess(for: ref)
let url = access?.url ?? ref.url
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(url)
} catch {
Self.logger.error("board failed to open: \(error.description, privacy: .public)")
access?.stop()
phase = .failed
appModel.recordLaunchFailure(path: ref.path, message: error.description)
openWindow(id: WindowID.welcome)
dismissWindow(id: WindowID.board, value: ref)
return
}
let recordID = appModel.boardRegistry.recordOpen(of: url, displayName: AppModel.displayName(of: store))
appModel.boardRegistry.setOpenNow(id: recordID)
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
phase = .open(store)
configureWindow(store: store, recordID: recordID)
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
// when welcome is not open, which is the ordinary case.
dismissWindow(id: WindowID.welcome)
}
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
/// close interception that makes the flush unavoidable, and the title-bar widget.
private func configureWindow(store: BoardStore, recordID: UUID) {
// Filled in here rather than at declaration because the closure captures `openWindow`, an
// environment action; until the board has loaded there is also nothing for Open Card to act
// on, which is exactly what the item's `nil` check reads.
cardOpener.open = openCard
windowController.onAttach = { window in
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
}
// The window may already be attached — `viewDidMoveToWindow` fires well before this task's
// load returns — so the placement is applied directly too rather than waiting for a callback
// that has already happened.
if let window = windowController.window {
windowController.onAttach?(window)
}
windowController.onFrameChanged = { frame in
appModel.boardRegistry.updateWindowFrame(
id: recordID,
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
)
}
windowController.onCloseRequested = {
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
windowController.closeAfterFlush()
}
}
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
// card windows share that machinery and have no board to describe. It goes in after the
// load rather than at attach because it carries the store; the controller installs it once,
// whichever of the two arrives second.
windowController.installTitlebarAccessory(
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
)
}
// MARK: - Closing
/// The safety net behind the close interception.
///
/// `windowShouldClose` covers ⌘W, File ▸ Close and the red button — every way a *user* closes a
/// window. It does not cover a window torn down some other way (a programmatic dismiss, a scene
/// SwiftUI decides to end), and a board whose session outlived its window would leave a watcher
/// running over nothing. So the disappear runs the same sequence; `AppModel.closeBoard` is
/// idempotent precisely so these two can both fire without the flush running twice.
///
/// Deliberately **not** the quit path: quit is `AppDelegate`'s, and it must complete before the
/// app exits rather than in a task nobody waits for.
private func endSessionIfStillOpen() {
guard appModel.session(for: ref) != nil else { return }
Task { @MainActor in
await appModel.closeBoard(ref: ref, cause: .userClose)
}
}
}