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
This commit is contained in:
+113
-30
@@ -4,6 +4,18 @@ 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 &
|
||||
@@ -11,13 +23,14 @@ import os
|
||||
/// 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(Card)
|
||||
case shows(CardPlacement)
|
||||
case dismisses
|
||||
}
|
||||
|
||||
// MARK: - The session seam
|
||||
|
||||
/// A card window's editor session — m4's no-op stand-in for the thing 05-card-window.md will build.
|
||||
/// 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
|
||||
@@ -32,8 +45,11 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
func endSession() async {
|
||||
guard !hasEnded else { return }
|
||||
hasEnded = true
|
||||
// m6: commit the open Edit session here (06-history-undo.md's session granularity), flushing
|
||||
// the debounced body save first.
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +73,12 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// 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).
|
||||
///
|
||||
/// The content is a placeholder — the two-column composition, the sidebar, Edit/Preview and the rest
|
||||
/// are the card-window milestone's.
|
||||
/// ### 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
|
||||
@@ -93,18 +113,42 @@ struct CardWindowHost: View {
|
||||
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(card)
|
||||
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
|
||||
.frame(minWidth: 360, minHeight: 240)
|
||||
// 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 }
|
||||
@@ -113,34 +157,42 @@ struct CardWindowHost: View {
|
||||
.onDisappear { finish() }
|
||||
}
|
||||
|
||||
private var minimumSize: CGSize {
|
||||
CardWindowMetrics.minimumSize(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let card {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.title)
|
||||
// The untitled placeholder is styling, not a title: a card with no `title` key
|
||||
// shows the word in secondary, never as if somebody had typed it
|
||||
// (01-storage-format.md § Frontmatter).
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(24)
|
||||
if let placement {
|
||||
CardWindowView(card: placement.card)
|
||||
} else {
|
||||
// Nothing to render and nothing worth animating: this window is on its way out.
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
private var card: Card? {
|
||||
/// 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(card) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
|
||||
case let .shows(placement) = Self.cardWindowFate(cardID: ref.cardID, in: store.snapshot)
|
||||
else { return nil }
|
||||
return card
|
||||
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 {
|
||||
card?.title.value ?? ""
|
||||
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.
|
||||
@@ -188,15 +240,39 @@ struct CardWindowHost: View {
|
||||
configureWindow()
|
||||
}
|
||||
|
||||
/// Size and placement: the last-used card-window size, cascaded (05-card-window.md, "New windows
|
||||
/// open at the last-used card-window size, cascaded").
|
||||
/// 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").
|
||||
///
|
||||
/// The size is app-wide rather than per-board or per-card — 02 § Per-board app state files "the
|
||||
/// last-used card-window size" under App-wide state explicitly. Per-*card* frame restoration is a
|
||||
/// separate promise in 05 ("frames restore per card across relaunch where state restoration
|
||||
/// allows") and belongs to the card-window milestone, which owns the per-card record it needs.
|
||||
/// 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)
|
||||
}
|
||||
@@ -209,6 +285,13 @@ struct CardWindowHost: View {
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user