The whole board-name area opens the popover — the chevron alone was the trigger
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
This commit is contained in:
@@ -56,13 +56,19 @@ extension FocusedValues {
|
||||
|
||||
// MARK: - The window-title widget
|
||||
|
||||
/// The titlebar widget: a quiet disclosure chevron whose one job is this popover.
|
||||
/// The titlebar widget: the board's name — and, on a git-mode Pro board, its branch — with a
|
||||
/// trailing disclosure chevron, whose one job is this popover.
|
||||
///
|
||||
/// **The popover is anchored to the widget itself** — it hangs from the chevron 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`).
|
||||
/// **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/branch/chevron button): the title and branch strings sit inside the same
|
||||
/// `Button`, so a click anywhere across the board's name — or its branch, when shown — opens the
|
||||
/// popover exactly as a click on the chevron always has.
|
||||
struct BoardInfoWidget: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -71,32 +77,126 @@ struct BoardInfoWidget: View {
|
||||
/// The tier and the git state this board's **session** composed with — read once, at the moment
|
||||
/// the widget is installed, and never re-derived (12-editions.md ▸ The entitlement: "a lapse
|
||||
/// never interrupts an open session"). `git` is a reference type and `@Observable`, so add-git
|
||||
/// flipping the mode redraws the popover without anything here being re-created.
|
||||
/// flipping the mode, or a branch switch, redraws the widget without anything here being
|
||||
/// re-created.
|
||||
let tier: Tier
|
||||
let git: HistoryStore?
|
||||
|
||||
@Bindable var presentation: BoardInfoPresentation
|
||||
|
||||
/// The widget's two strings, 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 or branch switch. `store.snapshot` and `git.branch` are both
|
||||
/// `@Observable`, so reading them here is what makes the title and branch live.
|
||||
private var summary: BoardInfoTitlebarSummary {
|
||||
BoardInfoTitlebarSummary(
|
||||
snapshotTitle: store.snapshot.title.value,
|
||||
rootURL: store.rootURL,
|
||||
tier: tier,
|
||||
mode: git?.mode ?? .none,
|
||||
branch: git?.branch
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
presentation.toggle()
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.imageScale(.small)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
// Sized like a titlebar control rather than by its glyph: the hit target has to be
|
||||
// clickable at titlebar scale, where the chevron alone is a few points across.
|
||||
.frame(width: 20, height: 18)
|
||||
.contentShape(Rectangle())
|
||||
HStack(spacing: 4) {
|
||||
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 branch string and chevron first when the two don't both
|
||||
// fit inside the width cap below — the board's own name is the more load-bearing
|
||||
// half of the pair.
|
||||
.layoutPriority(1)
|
||||
|
||||
if let branch = summary.branch {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(branch)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.imageScale(.small)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.system(size: 13))
|
||||
// A long board name (or branch) must not swallow the whole titlebar — capped rather
|
||||
// than left to grow, with the truncation above doing the rest. Height stays the
|
||||
// original chevron's, which is what keeps the accessory titlebar-appropriate.
|
||||
.frame(maxWidth: 400, alignment: .leading)
|
||||
.frame(height: 18)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Board Info")
|
||||
.accessibilityLabel("Board Info")
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
.accessibilityHint("Shows board info")
|
||||
// Populates the branch line the moment a git-mode board's window opens, rather than waiting
|
||||
// on the popover's own read (`BoardGitControls`'s `.task`, which only runs once the popover
|
||||
// has actually been opened once). The widget is on screen from the start, so it is the
|
||||
// earlier honest place to ask; `refreshBranch()` is already a no-op outside git mode, so this
|
||||
// costs nothing on the other four postures.
|
||||
.task { await git?.refreshBranch() }
|
||||
.popover(isPresented: $presentation.isPresented, arrowEdge: .bottom) {
|
||||
BoardInfoView(store: store, recents: recents, tier: tier, git: git)
|
||||
}
|
||||
}
|
||||
|
||||
/// What VoiceOver reads for the button, now that it says more than "Board Info": the board's
|
||||
/// name, plus the branch when the widget is showing one — `.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 {
|
||||
guard let branch = summary.branch else { return summary.title }
|
||||
return "\(summary.title), branch \(branch)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The widget's strings
|
||||
|
||||
/// **The window-title widget's two strings, as one pure function** of the board's on-disk title, its
|
||||
/// folder, and the session's git posture — pulled out so the fallback rule and the branch-visibility
|
||||
/// rule are each assertable without a widget on screen (`BoardInfoTitlebarSummaryTests`), the same
|
||||
/// reason `BoardGitSection.resolve` exists one level down in this file.
|
||||
///
|
||||
/// **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.** Shown only when the board is actually git-mode under Pro — `tier == .pro && mode ==
|
||||
/// .git` with a non-`nil` branch — the same condition family `BoardGitSection.resolve`'s `.branch`
|
||||
/// case covers. The free tier and an inert `.git` (mode `.none` or `.repoNested`) show no branch;
|
||||
/// neither does a git-mode board whose branch has not been read yet (`HistoryStore.branch` starts
|
||||
/// `nil` until `refreshBranch()` answers, which the widget's own `.task` kicks off at open).
|
||||
struct BoardInfoTitlebarSummary: Equatable {
|
||||
|
||||
let title: String
|
||||
let branch: String?
|
||||
|
||||
init(snapshotTitle: String?, rootURL: URL, tier: Tier, mode: BoardGitMode, branch: String?) {
|
||||
if let snapshotTitle, !snapshotTitle.isEmpty {
|
||||
self.title = snapshotTitle
|
||||
} else {
|
||||
self.title = rootURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
self.branch = (tier == .pro && mode == .git) ? branch : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget wearing AppKit's clothes, because SwiftUI has no way to put a view in the titlebar:
|
||||
@@ -128,9 +228,13 @@ func boardInfoTitlebarAccessory(
|
||||
)
|
||||
)
|
||||
// The titlebar lays its accessories out by fitting size, and a hosting view that measured itself
|
||||
// as zero would be an invisible, unclickable widget.
|
||||
// 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.
|
||||
hosting.sizingOptions = [.intrinsicContentSize]
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 20, height: 18)
|
||||
hosting.frame = NSRect(x: 0, y: 0, width: 200, height: 18)
|
||||
|
||||
let controller = NSTitlebarAccessoryViewController()
|
||||
controller.view = hosting
|
||||
|
||||
Reference in New Issue
Block a user