Files
lanework/Kanban/App/CardWindowHost.swift
T
rzen 1e65b7c986 Build the card window shell and lifecycle
The m4 scene plumbing was already honest — one WindowGroup value per
CardWindowRef enforces one-window-per-card, and CardWindowFate's
ancestor walk answered dismissal — so this card fills the window: a
two-column shell whose body column takes all resize flex and whose
sidebar width derives once from font metrics (26 characters of average
body advance plus em gutters), the five 05-ordered section headers as
placeholders, and the card body as selectable plain text until Preview
mode lands. The fate walk now returns a CardPlacement (card + lane), so
one pass answers both liveness and the live board › lane subtitle; a
board rename lands for free through displayName. Card windows remember
their frames per card in the board record (case-folded id keys,
unchanged-writes-nothing), restoring instead of cascading; only
unremembered cards take the last-used size and cascade. Store
acquisition stays gated on liveStore — a card window never opens a
board — and the close-flush hook stands with nothing to flush until the
Edit-session card.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 09:27:01 -04:00

320 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
import os
// MARK: - Fate
/// Where a card window's card is right now: the card itself, and the lane it is currently in.
///
/// **One resolution answers both of the window's questions**, which is why the lane rides along
/// rather than being looked up a second time: the card is what the window renders, and the lane is
/// half of its subtitle ("⟨board⟩ ⟨lane⟩", live-updating as the card moves — 05-card-window.md
/// ▸ Window). Two walks over the snapshot could disagree about which lane a card is in for exactly
/// one frame, and that frame is the one where the card just moved.
public struct CardPlacement: Equatable {
public let card: Card
public let lane: Lane
}
/// What the current snapshot says about a card window: render this card, or go away.
///
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
public enum CardWindowFate: Equatable {
case shows(CardPlacement)
case dismisses
}
// MARK: - The session seam
/// A card window's editor session — still a no-op stand-in for the thing 05-card-window.md will
/// build, and deliberately so: **the shell has no Edit session to flush yet.**
///
/// It exists so the close flush has something real to call and something real to be *ordered against*
/// (see `CardSessionFlushing`). The only behaviour it has is the one the ordering depends on: ending
/// twice does nothing the second time, which matters because two paths legitimately end a session —
/// the board's close flush drives it for every card window, and a card window closed on its own runs
/// it from its disappear.
@MainActor
final class CardWindowSession: CardSessionFlushing {
private var hasEnded = false
func endSession() async {
guard !hasEnded else { return }
hasEnded = true
// m6-card-body: commit the open Edit session here (06-history-undo.md's session
// granularity), flushing the debounced body save first — and, on a dismissal caused by a
// tombstone, the surgical body write 05 ▸ Deletion & lifecycle promises ("dismissal never
// eats typed work silently where a save can land"). Nothing exists to flush until the
// editor does; the hook's *position* in the sequence is what this milestone pins.
}
}
// MARK: - CardWindowHost
/// One card window (05-card-window.md).
///
/// ### Its whole identity is `(board, card)`
///
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
/// lanes for free — the key names neither — and it dismisses in the three cases where the key stops
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
/// any more, which is what a cross-board move looks like from here.
///
/// ### It can never outlive its board window
///
/// "The board window owns the board" (02 § Components) — so a card window whose board has no live
/// store, or whose board session has gone, dismisses immediately rather than becoming an orphan with
/// a store it acquired by itself. That covers the ordinary case (the board window closed and its
/// flush dismissed this one) and the odd one (the system restoring a card window from a previous
/// launch, which scene restoration is disabled precisely to prevent).
///
/// ### It is the window; `CardWindowView` is the content
///
/// This file owns identity, lifecycle, the title and subtitle, and where the window opens. The
/// two-column composition inside it is `CardWindowView`'s, and what fills those columns — the title
/// field, Preview/Edit, the raw-source outlet, the sidebar's five sections — arrives card by card
/// underneath a composition that does not move.
struct CardWindowHost: View {
let ref: CardWindowRef
@Environment(AppModel.self) private var appModel
@Environment(\.dismissWindow) private var dismissWindow
@State private var windowController = HostedWindowController()
@State private var session = CardWindowSession()
@State private var phase: Phase = .opening
private enum Phase {
case opening
case open(BoardStore)
case closing
}
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "card-window")
// MARK: - The lifecycle rule
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
///
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
/// (deleted outright, or moved to another board — the board half of the key no longer names it),
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
/// its own or its lane's. Only a live card in a live lane keeps its window.
///
/// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
static func cardWindowFate(cardID: String, in snapshot: BoardModel) -> CardWindowFate {
let identity = ItemID(rawValue: cardID)
for lane in snapshot.lanes {
guard let card = lane.cards.first(where: { $0.id == identity }) else { continue }
return lane.isDeleted || card.isDeleted
? .dismisses
: .shows(CardPlacement(card: card, lane: lane))
}
return .dismisses
}
/// The window's subtitle: "⟨board⟩ ⟨lane⟩" (05-card-window.md ▸ Window).
///
/// Pure, and taking both names as strings, because the *live* half is what matters and is the
/// easy half to lose: the board name follows a board rename through
/// `AppModel.displayName(of:)`, and the lane name follows the card between lanes because it is
/// re-derived from every snapshot rather than captured when the window opened. A window that
/// kept showing the lane its card was in an hour ago would be wrong in exactly the case the
/// subtitle exists for.
///
/// Untitled lanes render the same placeholder the board's lane header does — "Untitled" is a
/// rendering, never a value (03-board-ui.md § Card face).
static func subtitle(board: String, lane: String?) -> String {
"\(board) \(lane ?? "Untitled")"
}
// MARK: - View
var body: some View {
content
// Derived, like every other measurement in this window: the minimum is what the two
// columns need at the current text size, not a number chosen once at 13pt
// (`CardWindowMetrics`).
.frame(minWidth: minimumSize.width, minHeight: minimumSize.height)
.background(WindowAccessor(controller: windowController))
.navigationTitle(windowTitle)
// The window follows its card: both of these are re-derived from every snapshot, so a
// rename retitles the window and a lane move re-subtitles it with no notification of
// our own (05-card-window.md ▸ Window).
.navigationSubtitle(windowSubtitle)
.task { start() }
.onChange(of: shouldDismiss, initial: true) { _, dismisses in
guard dismisses else { return }
dismissWindow(id: WindowID.card, value: ref)
}
.onDisappear { finish() }
}
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
@ViewBuilder
private var content: some View {
if let placement {
CardWindowView(card: placement.card)
} else {
// Nothing to render and nothing worth animating: this window is on its way out.
Color.clear
}
}
/// Where this window's card is in this board's snapshot, or `nil` when it is not — which is the
/// same condition `shouldDismiss` reads, one moment before the window goes.
private var placement: CardPlacement? {
guard case let .open(store) = phase,
case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
else { return nil }
return placement
}
/// The card's title, with the face's own untitled rendering — a card with no `title` key is
/// "Untitled" in the title bar and the Window menu, never a blank strip.
private var windowTitle: String {
guard let placement else { return "" }
return placement.card.title.value ?? "Untitled"
}
private var windowSubtitle: String {
guard case let .open(store) = phase, let placement else { return "" }
return Self.subtitle(
board: AppModel.displayName(of: store),
lane: placement.lane.title.value
)
}
/// The dismissal decision, re-evaluated on every snapshot the store applies.
///
/// Two clauses, and the second is the safety net: the board's session vanishing means the board
/// window has finished tearing down, and a card window still on screen at that point has nothing
/// behind it. It is deliberately redundant with the close flush, which dismisses these windows
/// itself — a net is only useful when the thing it backs up has already failed.
private var shouldDismiss: Bool {
guard case let .open(store) = phase else { return false }
guard appModel.session(for: ref.board) != nil else { return true }
return Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot) == .dismisses
}
// MARK: - Opening
/// Joins the board's session, or dismisses.
///
/// **`liveStore(for:)` first, and no fallback to `acquire` on a closed board.** A card window
/// never opens a board: doing so would put a store — and a watcher — behind a window that,
/// by 02's ownership rule, is not allowed to exist. The `acquire` below can only hit the
/// already-open path, which is why its failure is logged rather than surfaced.
private func start() {
guard case .opening = phase else { return }
guard appModel.storeRegistry.liveStore(for: ref.boardURL) != nil else {
Self.logger.debug("card window has no live board — dismissing")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
let store: BoardStore
do throws(BoardLoadError) {
store = try appModel.storeRegistry.acquire(ref.boardURL)
} catch {
Self.logger.error("card window could not acquire its board: \(error.description, privacy: .public)")
phase = .closing
dismissWindow(id: WindowID.card, value: ref)
return
}
appModel.registerCardWindow(ref, session: session)
phase = .open(store)
configureWindow()
}
/// Size and placement — **the remembered frame first, the cascade second** (05-card-window.md
/// ▸ Window: "New windows open at the last-used card-window size, cascaded; frames restore per
/// card across relaunch where state restoration allows").
///
/// Two memories, and they answer different questions:
///
/// - **The last-used size is app-wide.** 02 § Per-board app state files "the last-used
/// card-window size" under App-wide state explicitly, so it lives in `AppPreferences` and a
/// card window opened on any board inherits the size of the last one the user sized.
/// - **The frame is per card**, and lives on the board's registry record beside the board
/// window's own frame — same home, same identity-keyed record, same files-first rule (nothing
/// app-private is ever written into the board folder). "Where state restoration allows" is
/// this: AppKit's scene restoration is disabled app-wide on purpose (`KanbanApp`), so a card
/// window does not come back by itself at relaunch — but the *next* time the user opens that
/// card, it opens where they left it.
///
/// A card with a remembered frame therefore does **not** cascade: a cascade over a deliberate
/// placement would move a window the user had already put somewhere. Only the windows with
/// nothing remembered take the running cascade point, which is what keeps a burst of freshly
/// opened cards from landing on top of each other.
private func configureWindow() {
// Read once here rather than per callback: this window's board has a session by now (the
// caller just registered against it), and the record id is what both memories are keyed on.
let recordID = appModel.session(for: ref.board)?.recordID
windowController.onAttach = { window in
if let recordID,
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
// Repositioned onto a live screen when the saved one is gone — the board window's
// own rule, shared rather than restated (`HostedWindowController.placement`).
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
return
}
if let size = AppPreferences.lastCardWindowSize {
window.setContentSize(size)
}
// `cascadeTopLeft(from:)` both places this window and returns the origin for the next
// one, so the running point is the whole cascade.
appModel.cardCascadePoint = window.cascadeTopLeft(from: appModel.cardCascadePoint)
}
if let window = windowController.window {
windowController.onAttach?(window)
}
windowController.onFrameChanged = { frame in
if let recordID {
appModel.boardRegistry.updateCardWindowFrame(
id: recordID,
cardID: ref.cardIdentity,
frame: WindowFrame(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height)
)
}
guard let window = windowController.window else { return }
let size = window.contentRect(forFrameRect: frame).size
guard size != AppPreferences.lastCardWindowSize else { return }
AppPreferences.setLastCardWindowSize(size)
}
}
// MARK: - Closing
/// Leaves the session and lets the store go.
///
/// The release rides **behind** the session's end rather than beside it: a session that has
/// something to commit (m6) needs the store it is committing through, and a refcount that hit
/// zero first would have stopped the watcher underneath it. In m4 the hook is a no-op and the
/// ordering costs one run-loop turn — the point is that the shape is already right.
private func finish() {
guard case let .open(store) = phase else { return }
phase = .closing
appModel.unregisterCardWindow(ref)
Task { @MainActor in
await session.endSession()
appModel.storeRegistry.release(store)
}
}
}