The titlebar widget grows from a 20×18 chevron into one button saying the board's name and, on a git-mode Pro board, its branch — click anywhere across it and the popover opens as before, anchored to the widget. BoardInfoTitlebarSummary is the pure seam for both strings (title falls back to the folder name per 01's naming rule; branch only under pro + git mode, live off the observable HistoryStore.branch). Board windows now hide the system title display through the same hideTitle slot card windows adopted — the widget says the name, so the chrome would only repeat it — while navigationTitle keeps feeding window.title to the Window menu, Exposé, VoiceOver and restoration. The widget also refreshes the branch eagerly at appearance: it used to populate only once the popover had been opened, which would have left the new branch line empty on a freshly opened board. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
385 lines
20 KiB
Swift
385 lines
20 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?
|
|
|
|
/// Whether this window's title is hidden from the title bar — **card and board windows**: the
|
|
/// card's name is shown as part of the card's body instead of the chrome (05-card-window.md ▸
|
|
/// Window), and the board's is said by the board-popover widget in the titlebar instead
|
|
/// (03-board-ui.md ▸ Board popover; `BoardWindowHost.configureWindow`). `nil` leaves AppKit's own
|
|
/// default (`.visible`) untouched — the restore-bootstrap window's posture, the one
|
|
/// `HostedWindowController`-hosted window with no opinion here, the same "nothing to do" posture
|
|
/// `titlebarAccessory` has on welcome (which never attaches a controller at all), the bootstrap
|
|
/// window, and now — for that slot specifically — card windows too.
|
|
///
|
|
/// A slot, not a one-shot write, for the accessory and toolbar's own reason: the value has to
|
|
/// survive the provisional-window swap (`detach()`'s doc comment) and reapply itself when the
|
|
/// real window attaches, which a write made once at `onAttach` time would not survive if that
|
|
/// closure only fired for the provisional window. `NSWindow.title` itself is a different slot
|
|
/// entirely — SwiftUI's `navigationTitle` sets it directly, and it is left alone on purpose: the
|
|
/// Window menu, Mission Control/Exposé, VoiceOver and state restoration all read the string, not
|
|
/// what the chrome draws from it.
|
|
private var titleVisibility: NSWindow.TitleVisibility?
|
|
|
|
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()
|
|
applyTitleVisibilityIfPossible()
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// MARK: Title visibility
|
|
|
|
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today
|
|
/// — the card-window seam (`CardWindowHost`, 05-card-window.md ▸ Window) and, since the
|
|
/// board-popover widget grew to say the board's name itself, the board-window one too
|
|
/// (`BoardWindowHost`, 03-board-ui.md ▸ Board popover). `window.title` is untouched by this call
|
|
/// on purpose; see the property's doc comment for why.
|
|
///
|
|
/// Safe to call whenever the caller learns it wants this — before the window exists (the value is
|
|
/// held and applied at `attach`) or after (applied immediately) — and safe to call more than once,
|
|
/// unlike the accessory and toolbar slots: writing `NSWindow.titleVisibility` twice has no side
|
|
/// effect worth guarding against, so this is not an install-once seam.
|
|
func hideTitle() {
|
|
titleVisibility = .hidden
|
|
applyTitleVisibilityIfPossible()
|
|
}
|
|
|
|
private func applyTitleVisibilityIfPossible() {
|
|
guard let window, let titleVisibility else { return }
|
|
window.titleVisibility = titleVisibility
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|