Files
lanework/Kanban/App/WindowAccessor.swift
T
rzen 71664dab02 Give card windows their own undo stacks and coarsen the close
Phase B of the two-level undo card: every card-window gesture — comment
post/delete/edit, body Edit sessions, style and details changes —
registers fine-grained on the window's own stack (window.undoManager
answers with it; board ⌘Z never sees mid-session card steps; an empty
window stack beeps, never falls through). Window close folds the stack
into one coarse values-based board step ("Edit card 'X'") — per-target
per-field later-wins merge, so foreign mid-session writes stay out by
construction, a no-net-change session registers nothing, and any stale
component skips the whole step. The comments/.trash purge defers with
the coarse step via a step-retirement seam on the providers: it runs
when the step leaves the board stack or the board session ends; the git
provider retires dropped steps on register, which keeps Pro's
purge-at-close-flush structural with no tier check. Interim on git
boards: gestures still auto-commit per debounce until phase C's
close-flush commit.

2432 tests in 418 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
2026-07-31 19:39:50 -04:00

344 lines
17 KiB
Swift

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)?
/// **The stack this window's ⌘Z crosses**, asked for afresh every time AppKit wants it —
/// 13-native-undo.md ▸ Rules' two levels (re-ruled 2026-07-31): a **board** window answers with
/// its session's stack, and a **card** window with its own, "standard per-window AppKit scoping".
///
/// A closure rather than a stored manager for two reasons: a board window's session does not
/// exist yet when the window attaches, and it stops existing at teardown while the window is
/// still closing — answering `nil` then is what keeps a torn-down board's stack from being
/// reachable through a window that outlived it by a run-loop turn.
///
/// `nil` on every window that has no stack of its own (welcome, the bootstrap, the template
/// chooser), which `BoardUndoRouting` reads as "the platform default".
var windowUndoManager: (() -> UndoManager?)?
/// The text manager this window hands back while a field editor holds the keyboard, and the one
/// it hands back when there is no board — 06-history-undo.md ▸ Undo routing, via
/// `BoardUndoRouting`. Created on demand, per window, which is what AppKit itself would have
/// done for a window whose delegate answered nothing.
private lazy var textUndoManager = UndoManager()
/// 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?
/// This window's toolbar, once something has given it one — the board and card windows'
/// customizable toolbars (03-board-ui.md ▸ Toolbar). A slot for the accessory's reason: welcome
/// and the bootstrap window have none, and the two that do only learn what goes in it after
/// their board has loaded.
private var toolbarController: WindowToolbarController?
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()
applyToolbarIfPossible()
}
/// Puts the previous delegate back and takes the titlebar accessory and toolbar off the window —
/// **without discarding them**. 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.
///
/// The held chrome survives a detach deliberately: its lifetime is this controller's, not the
/// sensing view's. SwiftUI dismantles and re-makes the background representable while it moves a
/// scene's content into its final window (observed on macOS 26: install arrives before any
/// window, a dismantle follows, and only *then* does the real window attach) — so chrome
/// discarded here would never reach the window it was made for. `attach` reinstalls whatever is
/// held; a controller that is genuinely done takes its slots down with it.
func detach() {
removeTitlebarAccessory()
removeToolbar()
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)
}
// MARK: Toolbar
/// Gives this window its toolbar — **once**, `installTitlebarAccessory`'s rule and for its
/// reason: a host may configure itself more than once, and a second toolbar would replace the
/// first along with the search field the first was hosting.
///
/// Installing before the window exists is legal — the toolbar is held and goes on at `attach`.
func installToolbar(_ controller: WindowToolbarController) {
guard toolbarController == nil else { return }
toolbarController = controller
applyToolbarIfPossible()
}
/// The window is SwiftUI's, and SwiftUI leaves `toolbar` alone for a scene that declares no
/// `.toolbar` modifier — which the board and card windows deliberately do not, since their
/// toolbars are `NSToolbar`s (see `WindowToolbarController` for why). The identity check is what
/// keeps a re-attach from replacing a live toolbar with itself and rebuilding every item.
private func applyToolbarIfPossible() {
guard let window, let toolbarController, window.toolbar !== toolbarController.toolbar else { return }
window.toolbar = toolbarController.toolbar
// The first honest answer to "what is installed", now that the toolbar has built its items
// from the saved configuration.
toolbarController.reportInstalledItems()
toolbarController.revalidate()
}
private func removeToolbar() {
guard let window, let toolbarController, window.toolbar === toolbarController.toolbar else { return }
window.toolbar = 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
}
/// The window-level half of 06-history-undo.md ▸ Undo routing (see `BoardUndoRouting`, which
/// owns the rule and the reasoning): this window's stack when the keyboard is on the content, a
/// text manager of this window's own while a field editor has it.
///
/// **Answered here rather than forwarded**, unlike the proxy's other selectors, on the one
/// condition that this window has a stack: `responds(to:)` reports this method whatever the
/// previous delegate does, so a `nil` return would leave a window with *no* undo manager at all
/// rather than the one AppKit creates for a delegate that stays silent. A window with no stack
/// still defers to SwiftUI's delegate if it has an opinion.
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
let board = windowUndoManager?()
if board == nil, let previousDelegate,
previousDelegate.responds(to: #selector(NSWindowDelegate.windowWillReturnUndoManager(_:))),
let inherited = previousDelegate.windowWillReturnUndoManager?(window) {
return inherited
}
return BoardUndoRouting.undoManager(
isTextEditing: BoardUndoRouting.isTextEditing(window.firstResponder),
board: board,
textFallback: textUndoManager
)
}
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)
}
}