Files
lanework/Kanban/UI/Board/BoardInfoPopover.swift
T
rzen 1d97a2931c The git surfaces leave the glass — tab, trail, branch line, and the remote pair, peeled
Step 3 of strategy/01-git-excision.md: the popover strip is Info/Theme/Sync, the titlebar widget says the name alone, the card window's History section and its slot go, Board ▸ Pull/Push comes out with the RemoteCommands scaffold, and View ▸ History re-tags from the commit trail to the deferred foreign-change journal. The Sync placeholder re-annotates to the future ops-based sync service. Four git test suites leave with the surfaces they pinned (BoardGitSetup, BoardInfoPopover, BranchSwitch, GitUndo). The git engine still compiles underneath, unreferenced by UI. 2,890 tests green.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 10:52:57 -04:00

459 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import SwiftUI
/// **The board popover** — "the one board-level surface" (03-board-ui.md § Board popover), and the
/// widget in the window's titlebar that opens it.
///
/// **Tabbed since 2026-08-07.** The symbol/name header stays at the top; below it sit the tabs —
/// **Info**, **Theme**, **Sync** — each the settings surface for one aspect of board configuration,
/// each settled in its own dedicated design session: `BoardInfoTabView`, the metrics dossier;
/// `BoardThemeTabView`, the Solid color / Pattern picker (the Background tab's original name, before
/// the same session widened it past the generated-only picker and folded manual styling back out to
/// Style… ⌥⌘S); and `BoardSyncTabView`, a standing placeholder for the future ops-based sync service.
/// The Git tab that once sat here left with the git excision (strategy/01-git-excision.md,
/// 2026-08-08).
///
/// **Selection resets to Info on every open** — the tab session's ruling, unaffected by the git
/// excision: see `BoardInfoTab`.
///
/// ### One home, deliberately
///
/// The popover has **no toolbar item** (§ Toolbar: "the window-title widget is its committed home
/// … and a second entry would muddy it"). It has exactly two ways in: the widget, and File ▸ Board
/// Info ⌘I, which is the same widget's popover reached from the keyboard (11-command-nexus.md's
/// class **C** — "the keyboard path is reachability … not bindings").
///
/// **And it is the board's only configuration home** (ruled 2026-08-07, reversing the 2026-07-31
/// popover/sheet split): setup briefly lived in a board settings sheet with its own menu command and
/// a row here pointing at it. The sheet is retired and Board ▸ Board Settings… left the menu bar with
/// it. So "one home per control" — the split's own promise — is now satisfied by there being one
/// surface, and ⌘I is the door to all of it.
// MARK: - Presentation state
/// Whether **this window's** board popover is open.
///
/// Per window rather than per board or per app, and that is the point: ⌘I has to mean "the board in
/// front", so the flag travels with the window through the focus system (`FocusedValues.boardInfo`)
/// exactly as the store does. Two board windows each hold their own and can never toggle each
/// other's — which a flag on the store could not promise the day a board is allowed two windows.
///
/// It is also why this is not in `TransientBoardState`: everything there is *board*-scoped and
/// shared with the board's card windows, and a popover living in one window's titlebar is not that.
@MainActor
@Observable
final class BoardInfoPresentation {
var isPresented = false
/// ⌘I's whole behaviour and the widget's alike. **Toggling, not opening**: a shortcut aimed at a
/// disclosure that could only ever open would leave the popover with no keyboard way out.
func toggle() {
isPresented.toggle()
}
}
/// The focused board window's popover, beside `FocusedValues.boardStore` — see that key for why
/// board-window menu items reach their window this way rather than through the app model.
struct FocusedBoardInfoKey: FocusedValueKey {
typealias Value = BoardInfoPresentation
}
extension FocusedValues {
var boardInfo: BoardInfoPresentation? {
get { self[FocusedBoardInfoKey.self] }
set { self[FocusedBoardInfoKey.self] = newValue }
}
}
// MARK: - The window-title widget
/// The titlebar widget: the board's glyph beside its name, with a trailing disclosure chevron, whose
/// one job is this popover.
///
/// **Was a two-line stack** (03-board-ui.md ▸ Board popover, the window-title widget passage): a
/// git-mode board's branch sat under the name in its own smaller, secondary line. The branch line
/// left with app-managed git (strategy/01-git-excision.md, 2026-08-08); the widget is back to the
/// single title line, vertically centred beside the glyph.
///
/// **The popover is anchored to the widget itself** — it hangs from the button rather than from the
/// window or the board — which is what makes the affordance and the surface read as one thing. A
/// `.popover` rather than a hand-driven `NSPopover` because SwiftUI's is already transient (a click
/// outside dismisses it), and because the content is SwiftUI either way; the AppKit half of this is
/// only the *placement* (`boardInfoTitlebarAccessory`).
///
/// **Whole-area clickable, not just the chevron** (the card that widened this from a 20×18 chevron
/// button to the full name/chevron button): the title string sits inside the same `Button`, so a
/// click anywhere across the board's name opens the popover exactly as a click on the chevron
/// always has.
struct BoardInfoWidget: View {
let store: BoardStore
let recents: StyleRecents
@Bindable var presentation: BoardInfoPresentation
/// The widget's title string, computed fresh on every body evaluation rather than cached
/// anywhere. That matters here specifically: `boardInfoTitlebarAccessory` builds this view
/// exactly **once** at install, so a value read anywhere but inside `body` would freeze at the
/// widget's birth and never see a later rename. `store.snapshot` is `@Observable`, so reading it
/// here is what makes the title live.
private var summary: BoardInfoTitlebarSummary {
BoardInfoTitlebarSummary(
snapshotTitle: store.snapshot.title.value,
rootURL: store.rootURL
)
}
var body: some View {
Button {
presentation.toggle()
} label: {
HStack(spacing: 6) {
// The board's own glyph beside its name — the identity pair the popover header
// states, restated where the board is named all day (2026-08-07). Read inside
// `body` for `summary`'s reason: `icon`/`iconColor` are `@Observable` fields, so a
// restyle from the popover repaints this widget without reinstalling it. Lenient on
// both dimensions — an unresolvable glyph draws the board default, an unresolvable
// tint draws the standard secondary.
//
// **Its own font, not the container's** (the two-line rework, 2026-08-07): at 22pt
// it draws about twice the height `.imageScale(.small)` gave it on the container's
// 13pt, which is what lets one glyph span the title and branch lines instead of
// sitting beside the upper one. The number is the icon's own size rather than a
// scale factor because that is the dimension being chosen — the block's height.
Image(systemName: ItemSymbol.name(store.snapshot.icon, fallback: ItemSymbol.board))
.font(.system(size: 22))
.foregroundStyle(iconTint)
// Decorative beside the name it repeats — the button's own label already says
// everything VoiceOver needs (`accessibilityLabel` below).
.accessibilityHidden(true)
// The identity block: the board's name. The `HStack`'s own centring puts it level
// with the glyph.
Text(summary.title)
// Styled like a titlebar title, because that is what it now stands in for
// (`BoardWindowHost` hides the system title display in favor of this widget).
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(1)
.truncationMode(.tail)
// Yields space to the chevron first when the title doesn't fit inside the width cap
// below — the board's identity is the more load-bearing half of the pair.
.layoutPriority(1)
Image(systemName: "chevron.down")
.imageScale(.small)
.fontWeight(.semibold)
.foregroundStyle(.secondary)
}
.font(.system(size: 13))
// A long board name must not swallow the whole titlebar — capped rather than left to
// grow, with the truncation above doing the rest. The height keeps the accessory
// titlebar-appropriate: no taller than a standard title bar carries.
.frame(maxWidth: 400, alignment: .leading)
.frame(height: 32)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help("Board Info")
.accessibilityLabel(accessibilityLabel)
.accessibilityHint("Shows board info")
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
BoardInfoView(store: store, recents: recents)
}
}
/// What VoiceOver reads for the button: the board's name — `.help` keeps the shorter "Board
/// Info" wording as the hover tooltip, and `.accessibilityHint` on the widget itself still names
/// what the button does.
private var accessibilityLabel: String {
summary.title
}
/// The widget glyph's tint: the board's `iconColor` where it resolves, the quiet secondary
/// otherwise — `CardFaceView`'s `iconTint` rule at the board's own level.
private var iconTint: AnyShapeStyle {
if let color = Palette.color(for: store.snapshot.iconColor) {
return AnyShapeStyle(color)
}
return AnyShapeStyle(.secondary)
}
}
// MARK: - The widget's strings
/// **The window-title widget's title string, as one pure function** of the board's on-disk title and
/// its folder — pulled out so the fallback rule is assertable without a widget on screen
/// (`BoardInfoTitlebarSummaryTests`).
///
/// **Title.** `AppModel.displayName(of:)` is the same rule applied to the window's actual title
/// (`BoardWindowHost.windowTitle` reads it, and `.navigationTitle` keeps feeding it to the Window
/// menu, Exposé, VoiceOver and restoration even though the title bar's own rendering of it is now
/// hidden — see `BoardWindowHost.configureWindow`): the on-disk `title`, falling back to the folder
/// name sans extension when absent or empty (01-storage-format.md § Board naming). Restated here
/// against the raw title string and `rootURL` rather than a `BoardStore`, so this seam is testable
/// with plain values and no fixture board on disk — the one duplication this card leaves behind
/// rather than reshaping `AppModel.displayName(of:)`'s signature to fit both call sites.
///
/// **Branch.** The widget once carried a second line — the board's git branch, shown on a git-mode
/// board — as a `branch` field here. That line left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); the summary is the title alone now.
struct BoardInfoTitlebarSummary: Equatable {
let title: String
init(snapshotTitle: String?, rootURL: URL) {
if let snapshotTitle, !snapshotTitle.isEmpty {
self.title = snapshotTitle
} else {
self.title = rootURL.deletingPathExtension().lastPathComponent
}
}
}
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
/// an `NSTitlebarAccessoryViewController` hosting the button, laid out `.leading` so it sits in the
/// title bar beside the window title rather than in the window's content.
///
/// `HostedWindowController.installTitlebarAccessory` owns the rest of the lifecycle — one per
/// window, removed on detach — for the same reason it owns the delegate proxying: the window is
/// SwiftUI's, and anything hung on it has to be taken back off.
@MainActor
func boardInfoTitlebarAccessory(
store: BoardStore,
recents: StyleRecents,
presentation: BoardInfoPresentation
) -> NSTitlebarAccessoryViewController {
let hosting = NSHostingView(
rootView: BoardInfoWidget(
store: store,
recents: recents,
presentation: presentation
)
)
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
// as zero would be an invisible, unclickable widget. `.intrinsicContentSize` re-measures on every
// SwiftUI update, so this starting frame only has to survive the first layout pass before the
// widget's real content replaces it — but that first pass is exactly what a 20×18 placeholder
// (the old chevron-only width) would clamp now that the widget's content can run out to 400pt:
// wide enough that the widest realistic first paint is never visibly clipped before the resize.
// The height is the widget's own two-line figure (2026-08-07), for the same reason the width is
// generous — a first pass clamped to the old one-line 18 would paint a clipped block.
hosting.sizingOptions = [.intrinsicContentSize]
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 32)
let controller = NSTitlebarAccessoryViewController()
controller.view = hosting
controller.layoutAttribute = .leading
return controller
}
// MARK: - Tabs
/// The popover's aspects, one tab each (03-board-ui.md § Board popover, the 2026-08-07 tab
/// restructure): **Info** (`BoardInfoTabView`), **Theme** (`BoardThemeTabView`) — and **Sync**
/// (`BoardSyncTabView`), added 2026-08-07 as a standing placeholder: the strip claims the position
/// now, the surface says honestly that nothing lives there yet, and the future sync-service
/// workstream is where its contents get ruled. A fourth tab, Git, sat here until the git excision
/// (strategy/01-git-excision.md, 2026-08-08) removed it. The raw values are the segmented control's
/// own labels, so the strip needs no separate label function.
enum BoardInfoTab: String, CaseIterable, Identifiable {
case info = "Info"
case theme = "Theme"
case sync = "Sync"
var id: Self { self }
// Membership is simply `allCases`, in `allCases`' own order (Info, Theme, Sync) — every board
// carries the whole strip, unconditionally.
}
// MARK: - The popover's content
/// The symbol/name header, then the tab bar, then the selected tab's surface — one view
/// (03-board-ui.md § Board popover).
///
/// Width is the style editor's — the number that keeps the Style… popover narrow enough to sit
/// beside a card — kept through the restructure so the popover's footprint didn't wander while the
/// tabs filled in; every settled tab (Info, Theme, Sync) kept it, so whether the tabbed surface
/// ever wants its own width remains open, but nothing has needed one yet.
struct BoardInfoView: View {
let store: BoardStore
let recents: StyleRecents
/// The selected tab, and **it resets to Info on every open** — a ruling, not an accident: the
/// popover is transient and Info is the board's face, and a remembered tab could strand
/// selection on a tab the next board doesn't offer. `@State` on the popover's content, which
/// `BoardInfoWidget` hands `.popover` fresh on every open, is exactly that rule and nothing more.
@State private var tab: BoardInfoTab = .info
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it — **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
private var inset: CGFloat {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(
store: BoardStore,
recents: StyleRecents
) {
self.store = store
self.recents = recents
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
// The board's own icon, inline with its name — the same field the embedded style
// editor's symbol section below writes, offered here too since a board's identity
// is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No
// `undo:` — the board popover has none of its own, so this reaches the board's
// stack exactly as the embedded editor's writes do.
SymbolPicker(
current: store.snapshot.icon.value,
fallback: ItemSymbol.board,
onSelect: { name in
StyleCommand.apply(
icon: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
},
// The colour row — the picker's 4×2 tint grid, writing `iconColor` through
// the same funnel the glyph writes `icon`: None removes the key, a well
// writes the palette name (2026-08-07).
currentColor: store.snapshot.iconColor.value,
onSelectColor: { name in
StyleCommand.apply(
iconColor: name.map { StyleChange.set($0) } ?? .remove,
to: .board,
in: store,
recents: recents
)
}
)
.disabled(!store.acceptsBoardMutations)
BoardRenameField(store: store)
}
}
.padding(inset)
Divider()
// The tab bar: a segmented control rather than a `TabView`, because the popover is a
// compact settings surface and the segmented idiom is the macOS shape for switching
// between a handful of peer panes inside one. The label is hidden visually but stays
// the control's accessibility name. It iterates `allCases` — every board carries the
// whole strip, unconditionally (03-board-ui.md ▸ Board popover).
Picker("Board configuration", selection: $tab) {
ForEach(BoardInfoTab.allCases) { tab in
Text(tab.rawValue)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, inset)
.padding(.top, inset)
// The selected tab's surface — Info and Theme settled 2026-08-07, each in its own
// dedicated session and its own file; Sync is that day's standing placeholder. Each pads
// itself by `inset`, so the switch adds nothing.
switch tab {
case .info:
BoardInfoTabView(store: store, inset: inset)
case .theme:
BoardThemeTabView(store: store, inset: inset)
case .sync:
BoardSyncTabView(inset: inset)
}
}
// The style editor's popover width, taken from the editor rather than restated — the number
// that keeps a compact settings popover narrow enough to sit beside a card. It is
// font-derived, so the whole surface scales with the grids inside it (10-accessibility.md).
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
}
// MARK: - Rename
/// The board rename field (03-board-ui.md § Board popover; 01-storage-format.md § Board naming).
///
/// The exits are the inline editors' — Return and focus loss commit, Escape abandons — but the
/// **placeholder is the whole of the board-naming rule made visible**: an empty field shows the
/// folder name, because that is what the window title will say, and committing empty is how a user
/// asks for exactly that. "Untitled" appears nowhere; boards do not have it.
///
/// **It is not born focused**, unlike the three inline editors. Those are each opened by a gesture
/// that means "edit this now"; this one is one control among several on a configuration surface,
/// where the keyboard path is Tab-reachability rather than a caret waiting in the first field
/// (11-command-nexus.md's class **C**).
///
/// Every handler is idempotent, for `InlineTitleField`'s reason: the exits overlap by construction —
/// Return commits and then something takes focus, which fires the focus-loss commit an instant
/// later — and `BoardStore.renameBoard` skips an unchanged title, so the second call writes nothing.
private struct BoardRenameField: View {
let store: BoardStore
@State private var draft = ""
@FocusState private var isFocused: Bool
var body: some View {
TextField(fallbackName, text: $draft)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
.focused($isFocused)
.onSubmit { store.renameBoard(draft) }
// **Escape steps outward one layer per press** (04-interactions.md ▸ Grammar): a dirty
// field abandons its edit and keeps the popover open, and an unedited one lets the press
// through to the popover's own dismissal. Reverting first is also what makes a dismissal
// safe on the paths where the press never reaches here — the focus-loss commit that
// follows sees a draft equal to what is on disk and writes nothing.
.onKeyPress(.escape) {
guard draft != committed else { return .ignored }
draft = committed
return .handled
}
.onChange(of: isFocused) { _, focused in
guard !focused else { return }
store.renameBoard(draft)
}
.onAppear { draft = committed }
// A foreign rename — an agent, a hand edit, a sync — landing behind an open popover
// updates the field, but never under the user's fingers: a draft being typed is the
// user's, and the reload is not an edit to it (02-architecture.md § Live-reload
// resilience, the same courtesy the inline editors get by tracking their UUID).
.onChange(of: committed) { _, title in
guard !isFocused else { return }
draft = title
}
// The popover can be dismissed without the field ever reporting focus loss, and a
// dismissal is a commit like any other click-away. Idempotent with the handler above.
.onDisappear { store.renameBoard(draft) }
// The read-only lock disables every mutating surface (02-architecture.md § The lock's
// scope) — and the popover *stays open* under it, which is the style popover's settled
// precedent: the lock is a condition the banner is already explaining, not a reason to
// yank a surface away. `acceptsBoardMutations` rather than `isReadOnly` alone so this
// field and the editor below it disable as one surface rather than in halves.
.disabled(!store.acceptsBoardMutations)
}
/// The `title` on disk, as the last reload read it — "" for a board that has none.
private var committed: String { store.snapshot.title.value ?? "" }
/// The folder name, sans extension: what the window title shows when `title` is absent
/// (01-storage-format.md § Board naming), and therefore the honest thing for an empty field to
/// promise. Read off `rootURL` rather than `snapshot.rootURL` so a Finder rename absorbed
/// mid-session shows here immediately.
private var fallbackName: String {
store.rootURL.deletingPathExtension().lastPathComponent
}
}