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 /// The titlebar accessory this window shows, once something has given it one — today the board /// popover's window-title widget (03-board-ui.md § Board popover), and only on board windows. /// `nil` on welcome, the bootstrap and card windows, which is why it is a slot rather than a /// constructor argument. private var titlebarAccessory: NSTitlebarAccessoryViewController? 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) // After `onAttach`, so placement has already happened: an accessory handed over before the // window existed is installed here instead, and one handed over later installs immediately. addTitlebarAccessoryIfPossible() } /// Puts the previous delegate back, and takes the titlebar accessory back out. Called when the /// hosting view goes away; the delegate half is 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() { removeTitlebarAccessory() titlebarAccessory = nil guard let window, window.delegate === self else { return } window.delegate = previousDelegate self.window = nil } // MARK: Titlebar accessory /// Gives this window a titlebar accessory — **once**, whatever the caller does. /// /// The guard is the whole of the install-once rule: AppKit keeps accessories in an array and /// would happily hold two identical widgets, and a board window's host may configure itself more /// than once (the load returns, the window attaches, SwiftUI re-evaluates). Installing before /// the window exists is legal — the accessory is held and goes in at `attach`. func installTitlebarAccessory(_ accessory: NSTitlebarAccessoryViewController) { guard titlebarAccessory == nil else { return } titlebarAccessory = accessory addTitlebarAccessoryIfPossible() } private func addTitlebarAccessoryIfPossible() { guard let window, let accessory = titlebarAccessory, !window.titlebarAccessoryViewControllers.contains(where: { $0 === accessory }) else { return } window.addTitlebarAccessoryViewController(accessory) } /// Removes ours and only ours, by identity: the index is looked up rather than assumed, because /// nothing promises this app owns the only accessory a window carries. private func removeTitlebarAccessory() { guard let window, let accessory = titlebarAccessory, let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0 === accessory }) else { return } window.removeTitlebarAccessoryViewController(at: index) } /// 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) } }