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
170 lines
7.4 KiB
Swift
170 lines
7.4 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()
|
|
|
|
@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).
|
|
BoardView(store: store, window: { windowController.window })
|
|
}
|
|
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
|
|
.focusedSceneValue(\.boardStore, store)
|
|
}
|
|
}
|
|
|
|
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(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, and the
|
|
/// close interception that makes the flush unavoidable.
|
|
private func configureWindow(recordID: UUID) {
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|