Implement toolbar customization for both windows

NSToolbar through the existing HostedWindowController rather than
SwiftUI's toolbar — for reasons that are contract, not taste: 03's
transient-search clause is a decision over the toolbar's current
contents, which NSToolbar publishes and SwiftUI's API cannot answer;
Undo/Redo are the system's nil-target responder-chain actions so the
toolbar items validate exactly as the menu rows do (disabled on base
boards, alive in m8 unchanged); and the search item hosts the real
NSSearchField with explicit first-responder control. Customization is
all system furniture — Customize sheet, drag rearrange, display-mode
popup, overflow, autosaved per window kind. Board default: the search
field alone, trailing; catalog adds New Card, New Lane, Undo, Redo,
Show Trash, every action extracted from its menu command so no second
predicate exists. Card default: the Edit Body / Raw Source toggles and
Add Attachment, mirroring their commands' own predicates live via
observation tracking. Removing the search item keeps the promise —
⌘F surfaces the same field as a transient strip under the title bar,
persisting until the query clears, and an overflowed item that cannot
take the keyboard falls through to the strip too.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 12:57:42 -04:00
parent 40322247e0
commit 06ee59e24b
13 changed files with 1683 additions and 131 deletions
+107
View File
@@ -0,0 +1,107 @@
import AppKit
// MARK: - Identifiers
extension NSToolbarItem.Identifier {
static let cardEditBody = Self("card.editBody")
static let cardRawSource = Self("card.rawSource")
static let cardAddAttachment = Self("card.addAttachment")
}
// MARK: - The card window's toolbar
/// The card window's toolbar (03-board-ui.md Toolbar; 05-card-window.md Window).
///
/// "**Card window default: Edit Body · Raw Source · Add Attachment** the window's three committed
/// functions, all discoverable from its toolbar; the catalog is the same trio." So the default set
/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of
/// items which is exactly what a window with three functions should offer.
///
/// ### The three items are the three menu rows, predicates included
///
/// - **Edit Body** is "a single toggle button (on-state in Edit mirroring the View Edit Body
/// checkmark)", and it disables while source mode is active. That clause is not restated here: the
/// predicate is `EditBodyCommand.isEnabled(body:rawSource:)`, the menu row's own, handed to the
/// item verbatim. The pathfinder's segmented Preview|Edit is retired, so this is one button.
/// - **Raw Source** is "likewise a toggle showing on-state", and its two directions are not
/// symmetric: on enters, off *applies* (`CardRawSourceSession.applyAndLeave`). A refused Apply
/// leaves source mode open, and the item's on-state simply fails to clear the same way the menu
/// row's checkmark does, because both read `isActive`.
/// - **Add Attachment** "stays enabled in every mode attachment operations never touch
/// `index.md`, so they're safe alongside a raw edit". Its predicate is likewise the row's own
/// (`AddAttachmentCommand.isEnabled`), which is scope plus the read-only lock and says nothing
/// about the body's mode.
///
/// Labels are the menu titles minus a trailing ellipsis, so File Add Attachment labels as **Add
/// Attachment** (03's own example).
@MainActor
enum CardToolbar {
static let identifier = "dev.rzen.indie.Kanban.card"
/// "The catalog is the same trio" so the defaults are the catalog, in 03's order.
static let defaultItems: [NSToolbarItem.Identifier] = [
.cardEditBody,
.cardRawSource,
.cardAddAttachment,
]
static func specs(
body: CardBodyPresentation,
rawSource: CardRawSourceSession,
attachments: CardAttachments
) -> [ToolbarItemSpec] {
[
.mirroring(
menuTitle: "Edit Body",
identifier: .cardEditBody,
symbol: "square.and.pencil",
behavior: .toggle(
isEnabled: { [weak body, weak rawSource] in
EditBodyCommand.isEnabled(body: body, rawSource: rawSource)
},
isOn: { [weak body] in body?.mode == .edit },
setOn: { [weak body] isOn in body?.setMode(isOn ? .edit : .preview) }
)
),
.mirroring(
menuTitle: "Raw Source",
identifier: .cardRawSource,
symbol: "doc.plaintext",
behavior: .toggle(
isEnabled: { [weak rawSource] in rawSource != nil },
isOn: { [weak rawSource] in rawSource?.isActive == true },
setOn: { [weak rawSource] isOn in
guard let rawSource else { return }
if isOn {
rawSource.enter()
} else {
rawSource.applyAndLeave()
}
}
)
),
.mirroring(
menuTitle: "Add Attachment…",
identifier: .cardAddAttachment,
symbol: "paperclip",
behavior: .button(
isEnabled: { [weak attachments] in AddAttachmentCommand.isEnabled(attachments) },
perform: { [weak attachments] in attachments?.add() }
)
),
]
}
static func controller(
body: CardBodyPresentation,
rawSource: CardRawSourceSession,
attachments: CardAttachments
) -> WindowToolbarController {
WindowToolbarController(
identifier: identifier,
specs: specs(body: body, rawSource: rawSource, attachments: attachments),
defaults: defaultItems
)
}
}