The owner's follow-up review on the shipped rectangles (Pipeline card 5004c540): height was close, so it stays exactly 2.1em on both controls; width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5, symbol 1.25) instead of one shared multiplier. The symbol glyph gets a small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of breath, not the old padded ring — applied to the face rect before sizing and fitting, still centred on the same midpoint. The collapsed colour swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's None slash whenever the stored value is nil or unresolvable, rather than sitting empty. Both mounting anchors (the card sidebar's side-by-side columns, the board popover beside the rename field) pick up .fixedSize() so the controls render at their own intrinsic size instead of stretching into whatever slack an HStack proposal leaves them. KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift updated for the per-role widths, the shared-height claim, the glyph inset rule, and the None-face predicate.
514 lines
27 KiB
Swift
514 lines
27 KiB
Swift
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
|
||
}
|
||
|
||
/// The widget's stand-in for the board window's brief life before it has a store — the loading
|
||
/// window's own name (`BoardWindowHost.loadingTitle`), styled to match the real widget's title
|
||
/// line, with no icon (the registry record carries no cached glyph, `BoardRecord`), no chevron and
|
||
/// no popover: there is nothing to open yet.
|
||
///
|
||
/// **Why the loading window needs any widget at all**, now that `KanbanApp`'s board scene declares
|
||
/// `.windowToolbarStyle(.unified(showsTitle: false))` unconditionally: that modifier is the scene's,
|
||
/// not this window's, so it cannot wait for the store the way `HostedWindowController.hideTitle()`
|
||
/// used to — every board window it creates keeps the system title hidden from its very first frame,
|
||
/// loading or not. Leaving the loading window with no name anywhere in its chrome would be exactly
|
||
/// what 02-architecture.md's "its chrome carrying the registry record's cached title" rules out, so
|
||
/// `BoardWindowHost.configureLoadingWindow` installs this in the system title's place and
|
||
/// `configureWindow` swaps it for `boardInfoTitlebarAccessory` the moment the real one exists.
|
||
@MainActor
|
||
func boardLoadingTitlebarAccessory(title: String) -> NSTitlebarAccessoryViewController {
|
||
let hosting = NSHostingView(
|
||
rootView: HStack(spacing: 6) {
|
||
// A glyph-shaped gap rather than the glyph itself — there is no cached icon to draw
|
||
// (`BoardRecord` carries none), but leaving the text flush left would have it jump right
|
||
// by the icon's width at the swap. 22pt is `BoardInfoWidget`'s own icon size.
|
||
Color.clear.frame(width: 22, height: 22)
|
||
Text(title)
|
||
// The same line the real widget draws (`BoardInfoWidget.body`), so the swap to the
|
||
// interactive widget reads as a content change close to in place, not a jump.
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.primary)
|
||
.lineLimit(1)
|
||
.truncationMode(.tail)
|
||
}
|
||
.frame(maxWidth: 400, alignment: .leading)
|
||
.frame(height: 32)
|
||
)
|
||
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,
|
||
// Board-level: project/container/identity-flavored, not the picker's
|
||
// domain-agnostic default (`CuratedSymbols`' three-set split, 2026-08-09) —
|
||
// this well is the board's own glyph, so it reaches for the same vocabulary
|
||
// the style editor's grid would offer a board target.
|
||
symbols: CuratedSymbols.availableBoards,
|
||
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)
|
||
// **Pinned to the control's own intrinsic size** (owner's 2026-08-10 follow-up on
|
||
// Pipeline card 5004c540: the two pickers' ratios have to actually render, here
|
||
// too). `SymbolGlyphView.sizeThatFits` honours any explicit finite width proposal
|
||
// — right for a caller that wants to constrain it, but wrong beside
|
||
// `BoardRenameField`'s flexible `TextField`: an `HStack` proposes each less-
|
||
// flexible child a share of the row's *remaining* width before the field's own
|
||
// turn, and without `.fixedSize()` the rectangle would happily accept whatever
|
||
// share that is rather than holding its 5:4 ratio.
|
||
.fixedSize()
|
||
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
|
||
}
|
||
}
|