The window-title widget arrives as a leading titlebar accessory — a quiet chevron on board windows only, installed and removed by the window controller's own attach lifecycle — anchoring the one board-level surface as a transient popover (the board window deliberately grows no toolbar item for it). Inside: board rename editing frontmatter title only (the folder is never renamed; an empty commit removes the key and the window title falls back to the folder name), the embedded shared style editor permanently targeting the board, and the labeled Git section that this milestone only reserves — a mode-none explanation and a disabled stub where m7's add-git, branch, remote, and authentication controls land. Cmd-I (File > Board Info) toggles it per window through a focused scene value, kept apart from board-scoped transient state since a titlebar popover belongs to one window, not to the board. Escape reverts a dirty rename field and falls through to dismiss otherwise; a foreign rename resyncs the field only while unfocused. 12 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
256 lines
12 KiB
Swift
256 lines
12 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)?
|
|
|
|
/// 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)
|
|
}
|
|
}
|