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
320 lines
15 KiB
Swift
320 lines
15 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|