Stand up the window architecture — welcome, board, card
Four scenes (welcome, restore bootstrap, board group, card group) with system restoration disabled in favor of the registry's open-now flags: set when a window actually opens, cleared only on user close, so quit — and crash — leave exactly the restoration set behind. AppModel joins windows to sessions (shared store, registry record, card refs, held security scope); CloseFlushCoordinator pins 02's strict close order as a seam-injected machine (card sessions end, windows drain, store flushes, record stamps, teardown) with named slots where m6/m7 flushes land. HostedWindowController proxies — never replaces — SwiftUI's window delegate to intercept windowShouldClose for the flush, report frames, and place saved frames onto live screens. Card windows are (board path, case-folded card id) values: reopen focuses, and a snapshot-pure fate function dismisses on delete, tombstone, tombstoned lane, or cross-board move. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import os
|
||||
|
||||
// MARK: - HostedWindowController
|
||||
|
||||
/// The `NSWindow` behind a SwiftUI scene, and the three things this app needs from it that SwiftUI
|
||||
/// does not expose: the window's frame as the user changes it, a chance to run work *before* the
|
||||
/// window closes, and the window object itself for placement.
|
||||
///
|
||||
/// ### The delegate is proxied, never replaced
|
||||
///
|
||||
/// SwiftUI owns its windows' delegates and uses them — scene teardown, tabbing, restoration all ride
|
||||
/// through it — so assigning `window.delegate = self` and walking away breaks the window in ways
|
||||
/// that show up much later and look like SwiftUI bugs. This object therefore **inserts itself in
|
||||
/// front** of whatever delegate is already there: it implements the three methods it cares about and
|
||||
/// forwards them on by hand, and for every other selector it claims to respond exactly when the
|
||||
/// previous delegate does and forwards the message wholesale through `forwardingTarget(for:)`. The
|
||||
/// `responds(to:)` override is what makes that safe — `NSWindow` caches which delegate methods exist
|
||||
/// at the moment the delegate is set, and a proxy that under-reported would silently swallow half of
|
||||
/// SwiftUI's own callbacks.
|
||||
///
|
||||
/// The alternative that was considered and rejected: observing `NSWindow.willCloseNotification`
|
||||
/// instead of intercepting `windowShouldClose`. It cannot work for the close flush — by the time
|
||||
/// that notification arrives the close has already been decided, and the flush's whole job is to
|
||||
/// happen *first* (02-architecture.md § Windows). Move and resize, which have nothing to veto, could
|
||||
/// have gone either way; they are delegate methods here so there is one mechanism rather than two.
|
||||
@MainActor
|
||||
final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
/// The window, once the view hierarchy has one. Weak: the window owns the view that owns nothing
|
||||
/// here, and a strong reference would keep a closed window alive.
|
||||
private(set) weak var window: NSWindow?
|
||||
|
||||
/// Whoever was the delegate before us — SwiftUI's own, in practice. Weak for the same reason
|
||||
/// `NSWindow.delegate` is: it is not ours to keep alive.
|
||||
///
|
||||
/// `nonisolated(unsafe)` because the two proxying overrides below (`responds(to:)` and
|
||||
/// `forwardingTarget(for:)`) override `NSObject` methods that are not actor-isolated and cannot
|
||||
/// be made so. The property is written only on the main actor, and every read is a message the
|
||||
/// Objective-C runtime is delivering to a window delegate — which AppKit does on the main thread.
|
||||
/// The alternative, `MainActor.assumeIsolated`, would turn any hypothetical off-main
|
||||
/// `respondsToSelector:` into a crash; a stale read of a weak reference is the milder failure.
|
||||
private nonisolated(unsafe) weak var previousDelegate: NSWindowDelegate?
|
||||
|
||||
/// Called once, when the window first appears. Placement (the saved frame, the card cascade)
|
||||
/// happens here.
|
||||
var onAttach: ((NSWindow) -> Void)?
|
||||
|
||||
/// Called on `windowDidMove` and at the end of a live resize — not during one, because saving a
|
||||
/// frame per mouse-moved event would write the registry file hundreds of times for one drag.
|
||||
var onFrameChanged: ((NSRect) -> Void)?
|
||||
|
||||
/// Called instead of closing, when non-`nil`. The handler runs the close flush and then closes
|
||||
/// the window itself through `closeAfterFlush()`. `nil` means "close normally", which is every
|
||||
/// window that has nothing to flush.
|
||||
var onCloseRequested: (() -> Void)?
|
||||
|
||||
/// Set by `closeAfterFlush()` so the re-entrant `windowShouldClose` lets the close through
|
||||
/// instead of starting a second flush.
|
||||
private var isFlushed = false
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||
|
||||
// MARK: Attachment
|
||||
|
||||
func attach(to window: NSWindow) {
|
||||
guard self.window !== window else { return }
|
||||
self.window = window
|
||||
if window.delegate !== self {
|
||||
// Guarding against self-proxying: re-attaching to a window we already front would
|
||||
// otherwise make `previousDelegate` point at this object and every forwarded selector an
|
||||
// infinite loop.
|
||||
previousDelegate = window.delegate
|
||||
window.delegate = self
|
||||
}
|
||||
onAttach?(window)
|
||||
}
|
||||
|
||||
/// Puts the previous delegate back. Called when the hosting view goes away; a no-op if something
|
||||
/// else has since taken the delegate, because stomping a third party's would be the bug this
|
||||
/// whole file exists to avoid.
|
||||
func detach() {
|
||||
guard let window, window.delegate === self else { return }
|
||||
window.delegate = previousDelegate
|
||||
self.window = nil
|
||||
}
|
||||
|
||||
/// Closes the window for real, after the flush has run. `performClose` rather than `close` so the
|
||||
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||
/// flag telling our own `windowShouldClose` to stand aside.
|
||||
func closeAfterFlush() {
|
||||
isFlushed = true
|
||||
window?.performClose(nil)
|
||||
}
|
||||
|
||||
// MARK: NSWindowDelegate
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
guard !isFlushed, let onCloseRequested else {
|
||||
return previousDelegate?.windowShouldClose?(sender) ?? true
|
||||
}
|
||||
onCloseRequested()
|
||||
// The window stays open with everything still on screen while the flush runs — which is also
|
||||
// what makes 02's "close waits for in-flight operations" implementable here later: the
|
||||
// banner's spinner has somewhere to spin.
|
||||
return false
|
||||
}
|
||||
|
||||
func windowDidMove(_ notification: Notification) {
|
||||
reportFrame()
|
||||
previousDelegate?.windowDidMove?(notification)
|
||||
}
|
||||
|
||||
func windowDidEndLiveResize(_ notification: Notification) {
|
||||
reportFrame()
|
||||
previousDelegate?.windowDidEndLiveResize?(notification)
|
||||
}
|
||||
|
||||
private func reportFrame() {
|
||||
guard let window else { return }
|
||||
onFrameChanged?(window.frame)
|
||||
}
|
||||
|
||||
// MARK: Proxying
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
if super.responds(to: aSelector) { return true }
|
||||
return previousDelegate?.responds(to: aSelector) ?? false
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
guard let previousDelegate, previousDelegate.responds(to: aSelector) else { return nil }
|
||||
return previousDelegate
|
||||
}
|
||||
|
||||
// MARK: Placement
|
||||
|
||||
/// Where a saved frame should actually open — the settled rule in 02-architecture.md § Windows,
|
||||
/// "per-board frame memory (repositioned onto a live screen if the saved one is gone)".
|
||||
///
|
||||
/// Pure, and taking the screens as an argument, because the interesting case is a display that is
|
||||
/// *not attached right now*: a board last closed on an external monitor must not reopen at
|
||||
/// coordinates nobody can see. Asking `NSScreen` inside would make that untestable and would
|
||||
/// hide the rule inside a window callback.
|
||||
///
|
||||
/// Intersection, not containment, is the test: a window straddling two displays or hanging
|
||||
/// slightly off the bottom of one is where the user left it, and AppKit's own
|
||||
/// `constrainFrameRect(_:to:)` nudges the remainder into view when the frame is set. Only a frame
|
||||
/// that lands on *no* live screen is relocated, and then it keeps its size and centers on the
|
||||
/// fallback — size is a preference, position is a place, and the place is what stopped existing.
|
||||
static func placement(for saved: WindowFrame, onScreens visibleFrames: [NSRect], fallback: NSRect) -> NSRect {
|
||||
let frame = NSRect(x: saved.x, y: saved.y, width: saved.width, height: saved.height)
|
||||
if visibleFrames.contains(where: { $0.intersects(frame) }) {
|
||||
return frame
|
||||
}
|
||||
return NSRect(
|
||||
x: fallback.midX - frame.width / 2,
|
||||
y: fallback.midY - frame.height / 2,
|
||||
width: frame.width,
|
||||
height: frame.height
|
||||
)
|
||||
}
|
||||
|
||||
/// `placement(for:onScreens:fallback:)` against the screens attached right now.
|
||||
static func placementOnCurrentScreens(for saved: WindowFrame) -> NSRect {
|
||||
let visibleFrames = NSScreen.screens.map(\.visibleFrame)
|
||||
let fallback = NSScreen.main?.visibleFrame ?? visibleFrames.first ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
||||
return placement(for: saved, onScreens: visibleFrames, fallback: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WindowAccessor
|
||||
|
||||
/// Hands a SwiftUI view's `NSWindow` to a `HostedWindowController`.
|
||||
///
|
||||
/// A zero-size, hidden `NSView` whose only job is `viewDidMoveToWindow()` — the moment AppKit itself
|
||||
/// declares the window known. The alternative idiom (read `view.window` from a dispatched block after
|
||||
/// `makeNSView`) is a guess about timing that is usually right; this one is never wrong.
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
|
||||
let controller: HostedWindowController
|
||||
|
||||
func makeCoordinator() -> HostedWindowController { controller }
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = WindowSensingView()
|
||||
view.onWindow = { [controller] window in
|
||||
controller.attach(to: window)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
|
||||
static func dismantleNSView(_ nsView: NSView, coordinator: HostedWindowController) {
|
||||
coordinator.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws nothing and wants no space — it is a hook wearing a view's clothes. Hosted as a
|
||||
/// `.background`, so even its zero-size frame is out of the layout's way.
|
||||
private final class WindowSensingView: NSView {
|
||||
|
||||
var onWindow: ((NSWindow) -> Void)?
|
||||
|
||||
override var intrinsicContentSize: NSSize { .zero }
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard let window else { return }
|
||||
onWindow?(window)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user