Build the board popover — title widget, rename, styling, git slot
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
This commit is contained in:
@@ -33,6 +33,12 @@ struct BoardWindowHost: View {
|
||||
/// alive for exactly as long as this window exists.
|
||||
@State private var windowController = HostedWindowController()
|
||||
|
||||
/// This window's board popover, open or not (03-board-ui.md § Board popover). `@State` for the
|
||||
/// window controller's reason — one per window, living exactly as long as the window — which is
|
||||
/// also what makes ⌘I mean "the board in front" rather than "some board": the flag reaches the
|
||||
/// menu item through the focus system, like the store.
|
||||
@State private var boardInfo = BoardInfoPresentation()
|
||||
|
||||
@State private var phase: Phase = .opening
|
||||
|
||||
private enum Phase {
|
||||
@@ -79,8 +85,10 @@ struct BoardWindowHost: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
// "The board in front", for the menu items that act on it (`LaneWidthCommands`).
|
||||
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
||||
// beside it the window's own popover flag, which is what File ▸ Board Info toggles.
|
||||
.focusedSceneValue(\.boardStore, store)
|
||||
.focusedSceneValue(\.boardInfo, boardInfo)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,16 +131,16 @@ struct BoardWindowHost: View {
|
||||
appModel.beginSession(ref: ref, store: store, recordID: recordID, access: access)
|
||||
phase = .open(store)
|
||||
|
||||
configureWindow(recordID: recordID)
|
||||
configureWindow(store: store, recordID: recordID)
|
||||
|
||||
// "Opening a board from welcome closes welcome" (02 § Launch and window lifecycle). Harmless
|
||||
// when welcome is not open, which is the ordinary case.
|
||||
dismissWindow(id: WindowID.welcome)
|
||||
}
|
||||
|
||||
/// Wires the window: the saved frame on the way in, frame changes on the way back out, and the
|
||||
/// close interception that makes the flush unavoidable.
|
||||
private func configureWindow(recordID: UUID) {
|
||||
/// Wires the window: the saved frame on the way in, frame changes on the way back out, the
|
||||
/// close interception that makes the flush unavoidable, and the title-bar widget.
|
||||
private func configureWindow(store: BoardStore, recordID: UUID) {
|
||||
windowController.onAttach = { window in
|
||||
guard let saved = appModel.boardRegistry.record(id: recordID)?.windowFrame else { return }
|
||||
window.setFrame(HostedWindowController.placementOnCurrentScreens(for: saved), display: true)
|
||||
@@ -157,6 +165,15 @@ struct BoardWindowHost: View {
|
||||
windowController.closeAfterFlush()
|
||||
}
|
||||
}
|
||||
|
||||
// The window-title widget (03-board-ui.md § Board popover) — **board windows only**, which
|
||||
// is why it is installed here rather than in `WindowAccessor`: welcome, the bootstrap and
|
||||
// card windows share that machinery and have no board to describe. It goes in after the
|
||||
// load rather than at attach because it carries the store; the controller installs it once,
|
||||
// whichever of the two arrives second.
|
||||
windowController.installTitlebarAccessory(
|
||||
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Closing
|
||||
|
||||
@@ -60,6 +60,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
/// 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
|
||||
@@ -75,17 +81,52 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
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. Called when the hosting view goes away; 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.
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user