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 /// This window's body column — the Preview/Edit mode, and the ⌘F hook a menu item reaches /// through the focus system (`CardBodyPresentation`). @State private var bodyPresentation = CardBodyPresentation() 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) // Edit ▸ Find (⌘F) is find-in-text in a card window (11-command-nexus.md) — the menu // item reaches the frontmost one's body surface through this, exactly as board-window // items reach their window's store (`FocusedBoardStoreKey`). .focusedSceneValue(\.cardBody, bodyPresentation) .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 case let .open(store) = phase, let placement { CardWindowView( card: placement.card, cardFolder: Self.cardFolder(root: store.rootURL, placement: placement), bodyPresentation: bodyPresentation, // "Under the read-only lock the controls disable in place — an in-content mutation // menu validation can't reach" (05 ▸ Preview). The checkbox is that control, and // the store's own lock is the whole predicate. isEditable: !store.isReadOnly, onToggleTask: { offset, checked in store.toggleTaskMarker(inCard: placement.card.id, bodyOffset: offset, checked: checked) } ) } else { // Nothing to render and nothing worth animating: this window is on its way out. Color.clear } } /// `//` — the card's own folder, which is what its body's relative images and /// links resolve against (05-card-window.md ▸ Preview). /// /// Built off the store's *current* `rootURL` rather than the ref's captured one, for /// `BoardStore.liveItem`'s reason: a mid-session folder rename moves the board, and a preview /// resolving images against where the board used to be would quietly stop showing them. static func cardFolder(root: URL, placement: CardPlacement) -> URL { root .appendingPathComponent(placement.lane.id.rawValue, isDirectory: true) .appendingPathComponent(placement.card.id.rawValue, isDirectory: true) } /// 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) } } }