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
344 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|