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:
@@ -29,6 +29,9 @@ struct BoardWindowHost: View {
|
||||
@Environment(AppModel.self) private var appModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@Environment(\.dismissWindow) private var dismissWindow
|
||||
/// The transient search strip's arrival and departure has a reduced variant like every other
|
||||
/// appearance in the app (10-accessibility.md; `Motion.transientSearchTransition`).
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// The window's own controller — `@State` so it outlives body evaluations and so SwiftUI keeps it
|
||||
/// alive for exactly as long as this window exists.
|
||||
@@ -86,6 +89,15 @@ struct BoardWindowHost: View {
|
||||
case let .open(store):
|
||||
VStack(spacing: 0) {
|
||||
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($0) }
|
||||
// **⌘F's fallback**, and only that: the search field's home is the toolbar item
|
||||
// (`BoardToolbar`), and this strip exists for the window where the user has taken
|
||||
// that item out — "with the field removed from the toolbar, invoking it surfaces the
|
||||
// field transiently until the search clears" (03-board-ui.md ▸ Toolbar). It sits
|
||||
// directly under the title bar, where the item it stands in for would be.
|
||||
if boardSearch.isTransient {
|
||||
BoardSearchBar(store: store, presentation: boardSearch)
|
||||
.transition(Motion.transientSearchTransition(reduced: reduceMotion))
|
||||
}
|
||||
// The window is handed to the board as a closure, not a value: `WindowAccessor`
|
||||
// attaches after this body first runs, and the lane-resize drag needs the *live*
|
||||
// window to grow at its right edge (03-board-ui.md § Lane).
|
||||
@@ -99,21 +111,14 @@ struct BoardWindowHost: View {
|
||||
search: boardSearch
|
||||
)
|
||||
}
|
||||
// **The board window's toolbar: the search field, nothing else** (03-board-ui.md ▸
|
||||
// Toolbar, "trailing, the one default item; the titlebar stays clean"). It is a toolbar
|
||||
// rather than a strip inside the content because that is where 03 puts it, and it hosts
|
||||
// an `NSSearchField` rather than `.searchable` for the reasons `BoardSearchField`
|
||||
// records — explicit first-responder control, and stock key behaviour.
|
||||
//
|
||||
// m6-toolbar: the rest of 03's toolbar story is the customization card's — the
|
||||
// Customize palette, the New Card / New Lane / Undo / Redo / Show Trash catalog, and
|
||||
// with it ⌘F's transient surfacing of a *removed* field. That work replaces this
|
||||
// declaration with an identified, customizable toolbar; the item itself does not move.
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
BoardSearchField(store: store, presentation: boardSearch)
|
||||
.frame(width: 220)
|
||||
}
|
||||
// The transient strip's two dismissal inputs (`BoardSearchPresentation
|
||||
// .transientPersists`): it stays while a query is filtering the board or while the field
|
||||
// holds the keyboard, and goes when neither is true.
|
||||
.onChange(of: store.searchQuery) { _, query in
|
||||
boardSearch.dismissTransientIfCleared(query: query)
|
||||
}
|
||||
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
||||
}
|
||||
// "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, its
|
||||
@@ -267,6 +272,12 @@ struct BoardWindowHost: View {
|
||||
windowController.installTitlebarAccessory(
|
||||
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
|
||||
)
|
||||
|
||||
// The board's customizable toolbar (03-board-ui.md ▸ Toolbar) — installed here for the
|
||||
// accessory's reason exactly: it carries the store, and it is a board window's, not every
|
||||
// hosted window's. Its search item is the search field's home, and it is what tells
|
||||
// `boardSearch` whether that home still exists.
|
||||
windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch))
|
||||
}
|
||||
|
||||
// MARK: - Closing
|
||||
|
||||
@@ -515,6 +515,14 @@ struct CardWindowHost: View {
|
||||
// caller just registered against it), and the record id is what both memories are keyed on.
|
||||
let recordID = appModel.session(for: ref.board)?.recordID
|
||||
|
||||
// The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the
|
||||
// window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸
|
||||
// Window). It carries the three window-scoped handles above rather than a store, which is
|
||||
// why it is installed here and not at attach: those are this window's, and so is it.
|
||||
windowController.installToolbar(
|
||||
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
|
||||
)
|
||||
|
||||
windowController.onAttach = { window in
|
||||
if let recordID,
|
||||
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
|
||||
|
||||
@@ -66,6 +66,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
/// constructor argument.
|
||||
private var titlebarAccessory: NSTitlebarAccessoryViewController?
|
||||
|
||||
/// This window's toolbar, once something has given it one — the board and card windows'
|
||||
/// customizable toolbars (03-board-ui.md ▸ Toolbar). A slot for the accessory's reason: welcome
|
||||
/// and the bootstrap window have none, and the two that do only learn what goes in it after
|
||||
/// their board has loaded.
|
||||
private var toolbarController: WindowToolbarController?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||
|
||||
// MARK: Attachment
|
||||
@@ -84,14 +90,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
// 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()
|
||||
applyToolbarIfPossible()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Puts the previous delegate back, and takes the titlebar accessory and toolbar 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
|
||||
removeToolbar()
|
||||
toolbarController = nil
|
||||
guard let window, window.delegate === self else { return }
|
||||
window.delegate = previousDelegate
|
||||
self.window = nil
|
||||
@@ -127,6 +137,37 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
||||
window.removeTitlebarAccessoryViewController(at: index)
|
||||
}
|
||||
|
||||
// MARK: Toolbar
|
||||
|
||||
/// Gives this window its toolbar — **once**, `installTitlebarAccessory`'s rule and for its
|
||||
/// reason: a host may configure itself more than once, and a second toolbar would replace the
|
||||
/// first along with the search field the first was hosting.
|
||||
///
|
||||
/// Installing before the window exists is legal — the toolbar is held and goes on at `attach`.
|
||||
func installToolbar(_ controller: WindowToolbarController) {
|
||||
guard toolbarController == nil else { return }
|
||||
toolbarController = controller
|
||||
applyToolbarIfPossible()
|
||||
}
|
||||
|
||||
/// The window is SwiftUI's, and SwiftUI leaves `toolbar` alone for a scene that declares no
|
||||
/// `.toolbar` modifier — which the board and card windows deliberately do not, since their
|
||||
/// toolbars are `NSToolbar`s (see `WindowToolbarController` for why). The identity check is what
|
||||
/// keeps a re-attach from replacing a live toolbar with itself and rebuilding every item.
|
||||
private func applyToolbarIfPossible() {
|
||||
guard let window, let toolbarController, window.toolbar !== toolbarController.toolbar else { return }
|
||||
window.toolbar = toolbarController.toolbar
|
||||
// The first honest answer to "what is installed", now that the toolbar has built its items
|
||||
// from the saved configuration.
|
||||
toolbarController.reportInstalledItems()
|
||||
toolbarController.revalidate()
|
||||
}
|
||||
|
||||
private func removeToolbar() {
|
||||
guard let window, let toolbarController, window.toolbar === toolbarController.toolbar else { return }
|
||||
window.toolbar = nil
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
// MARK: - The vocabulary
|
||||
|
||||
/// **A toolbar item's label is its menu row's title** (03-board-ui.md ▸ Toolbar): "Toolbar item
|
||||
/// labels match their menu-item titles exactly (Show Trash, Edit Body, Raw Source, …), minus any
|
||||
/// trailing ellipsis (macOS convention: "Add Attachment…" labels as Add Attachment) — one vocabulary
|
||||
/// everywhere, and the customize palette self-documents against the menus."
|
||||
///
|
||||
/// One function rather than a hand-written second spelling per item, because the failure this rule
|
||||
/// guards against is drift: a menu row renamed without its toolbar item is two names for one
|
||||
/// function, and the customize palette is precisely where a user compares the two.
|
||||
///
|
||||
/// The **one exception is Undo/Redo**, which is not expressible here and is not meant to be:
|
||||
/// `NSUndoManager` rewrites their menu titles as the stack changes ("Undo Move Card"), and a toolbar
|
||||
/// label does not track that — so those two items are built from a static label instead of from a
|
||||
/// menu title (`ToolbarItemSpec.staticLabel`).
|
||||
enum ToolbarVocabulary {
|
||||
|
||||
/// `menuTitle` minus one trailing ellipsis — the "…" character and the three-period spelling
|
||||
/// alike, since a title is only ever written one of those two ways and both mean the same thing.
|
||||
static func label(menuTitle: String) -> String {
|
||||
var title = Substring(menuTitle)
|
||||
if title.hasSuffix("…") {
|
||||
title = title.dropLast()
|
||||
} else if title.hasSuffix("...") {
|
||||
title = title.dropLast(3)
|
||||
}
|
||||
while title.last == " " {
|
||||
title = title.dropLast()
|
||||
}
|
||||
return String(title)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One item
|
||||
|
||||
/// One toolbar item, as a value: its identity, its vocabulary, and what it does.
|
||||
///
|
||||
/// **It is a description, not a view.** The item's `NSToolbarItem` is built from this by
|
||||
/// `WindowToolbarController` — twice, in fact, since the customization palette holds its own copy of
|
||||
/// every item — so nothing here may be a control. Keeping it a value is also what makes the two
|
||||
/// things worth testing testable without a window: the label the vocabulary rule derives, and the
|
||||
/// predicates the item mirrors from its menu row (03 ▸ Toolbar: "every function they host already has
|
||||
/// a menu item + shortcut ... nothing below is anyone's only path").
|
||||
///
|
||||
/// The predicates are **the menu rows' own**, passed in by the catalogs — `EditBodyCommand.isEnabled`
|
||||
/// and `AddAttachmentCommand.isEnabled` are handed here verbatim rather than re-derived, which is the
|
||||
/// only way "the toolbar mirrors the menu" can stay true of code as well as of prose.
|
||||
@MainActor
|
||||
struct ToolbarItemSpec {
|
||||
|
||||
let identifier: NSToolbarItem.Identifier
|
||||
let label: String
|
||||
/// The SF Symbol the item draws. `nil` for a hosted control, which draws itself.
|
||||
let symbol: String?
|
||||
let behavior: Behavior
|
||||
|
||||
/// What the item *is*, which is also what kind of `NSToolbarItem` it becomes.
|
||||
@MainActor
|
||||
enum Behavior {
|
||||
/// A push button: an action, and the predicate its menu row validates against.
|
||||
case button(isEnabled: () -> Bool, perform: () -> Void)
|
||||
/// A toggle showing on-state — Show Trash, Edit Body, Raw Source (03 ▸ Toolbar).
|
||||
case toggle(isEnabled: () -> Bool, isOn: () -> Bool, setOn: (Bool) -> Void)
|
||||
/// An action sent up the responder chain with no target of our own — **Undo and Redo**, which
|
||||
/// is how their menu rows work too, so "matching their menu items" is one mechanism rather
|
||||
/// than two (03 ▸ Toolbar; 06-history-undo.md).
|
||||
case responderAction(Selector)
|
||||
/// A hosted control — the board's search field. `make` is handed `true` when the view is
|
||||
/// bound for the toolbar itself and `false` when it is the customization palette's copy, so
|
||||
/// only the real one claims window-scoped wiring.
|
||||
case control(width: CGFloat, make: (_ willBeInsertedIntoToolbar: Bool) -> NSView)
|
||||
}
|
||||
|
||||
/// The vocabulary rule applied: an item that mirrors a menu row takes that row's title, minus a
|
||||
/// trailing ellipsis.
|
||||
static func mirroring(
|
||||
menuTitle: String,
|
||||
identifier: NSToolbarItem.Identifier,
|
||||
symbol: String?,
|
||||
behavior: Behavior
|
||||
) -> Self {
|
||||
Self(
|
||||
identifier: identifier,
|
||||
label: ToolbarVocabulary.label(menuTitle: menuTitle),
|
||||
symbol: symbol,
|
||||
behavior: behavior
|
||||
)
|
||||
}
|
||||
|
||||
/// The Undo/Redo exception, and the search field (a control, not a command): a label written
|
||||
/// here because there is no menu title to derive it from.
|
||||
static func staticLabel(
|
||||
_ label: String,
|
||||
identifier: NSToolbarItem.Identifier,
|
||||
symbol: String?,
|
||||
behavior: Behavior
|
||||
) -> Self {
|
||||
Self(identifier: identifier, label: label, symbol: symbol, behavior: behavior)
|
||||
}
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// The item's live enablement. Responder-chain items and hosted controls answer `true`: the
|
||||
/// first is validated by the chain itself (which is the point of it), and the second has no
|
||||
/// enablement of its own.
|
||||
var isEnabled: Bool {
|
||||
switch behavior {
|
||||
case let .button(isEnabled, _): isEnabled()
|
||||
case let .toggle(isEnabled, _, _): isEnabled()
|
||||
case .responderAction, .control: true
|
||||
}
|
||||
}
|
||||
|
||||
/// The item's on-state, or `nil` for the items that have none.
|
||||
var isOn: Bool? {
|
||||
switch behavior {
|
||||
case let .toggle(_, isOn, _): isOn()
|
||||
case .button, .responderAction, .control: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Firing the item: a button performs, a toggle flips. A no-op for the two kinds AppKit drives
|
||||
/// itself.
|
||||
func activate() {
|
||||
switch behavior {
|
||||
case let .button(_, perform): perform()
|
||||
case let .toggle(_, isOn, setOn): setOn(!isOn())
|
||||
case .responderAction, .control: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The toolbar
|
||||
|
||||
/// A window's toolbar: **customizable, macOS-native, and never a function's only home**
|
||||
/// (03-board-ui.md ▸ Toolbar).
|
||||
///
|
||||
/// ### Why `NSToolbar` rather than SwiftUI's `.toolbar(id:)`
|
||||
///
|
||||
/// SwiftUI's customizable toolbar would answer most of 03's clauses, and it was the first choice.
|
||||
/// Four requirements sent this to AppKit instead, and each is normative rather than aesthetic:
|
||||
///
|
||||
/// - **⌘F has to know whether the search item is installed.** "With the field removed from the
|
||||
/// toolbar, invoking it surfaces the field transiently" (03) — a decision that needs to read the
|
||||
/// toolbar's *current* contents. `NSToolbar` publishes them (`items`, plus the will-add/did-remove
|
||||
/// delegate callbacks); SwiftUI's toolbar API has no such query, and reaching around it into the
|
||||
/// `NSToolbar` it happens to own means matching identifiers SwiftUI derives rather than ones this
|
||||
/// app spells.
|
||||
/// - **Undo and Redo have to reach the responder chain.** Their menu rows are the system's own —
|
||||
/// nil-target `undo:`/`redo:` — and "matching their menu items" (03) is literal here: a toolbar
|
||||
/// item with the same nil-target action validates and fires through exactly the same lookup, so
|
||||
/// the pair is disabled today for the same reason the menu rows are, and lights up in m8 with no
|
||||
/// change here. A SwiftUI `Button` cannot express that.
|
||||
/// - **The search item hosts a real `NSSearchField`** with explicit first-responder control, settled
|
||||
/// in m5 for reasons `BoardSearchFieldController` records (⌘F must focus it from a menu item;
|
||||
/// Escape in an empty field must hand the keyboard back to the strip). A toolbar that already
|
||||
/// speaks AppKit hosts it directly.
|
||||
/// - **The window is already proxied.** `HostedWindowController` fronts SwiftUI's window delegate
|
||||
/// and installs the board's titlebar accessory; a toolbar is the same kind of thing hung on the
|
||||
/// same window, through the same install-once seam.
|
||||
///
|
||||
/// What the platform gives back, and what 03 asks for by name: right-click ▸ Customize Toolbar…,
|
||||
/// drag to rearrange, the system overflow, and the icon/text display options —
|
||||
/// `allowsUserCustomization` and `allowsDisplayModeCustomization` below are those two sentences.
|
||||
/// The user's arrangement persists through `autosavesConfiguration`, keyed on the toolbar's
|
||||
/// identifier, so it is per *window kind* (every board window shares one arrangement) exactly as
|
||||
/// Finder's is.
|
||||
///
|
||||
/// ### Validation is observed, not polled
|
||||
///
|
||||
/// AppKit validates visible items on its own schedule (`NSToolbarItem.autovalidates`), which is tied
|
||||
/// to user events. The state these items mirror can change without one — a foreign reload flipping
|
||||
/// the read-only lock, a raw-source Apply landing — so the controller *observes* every predicate it
|
||||
/// was given (`withObservationTracking`) and re-validates when any of them would answer differently.
|
||||
/// The two mechanisms are complementary: AppKit's covers the ordinary case, this covers the case
|
||||
/// where nothing the user did caused the change.
|
||||
@MainActor
|
||||
final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
|
||||
let toolbar: NSToolbar
|
||||
|
||||
/// The catalog, in palette order — every item the user may install (03: "Catalog (available via
|
||||
/// Customize)").
|
||||
private let specs: [NSToolbarItem.Identifier: ToolbarItemSpec]
|
||||
private let catalog: [NSToolbarItem.Identifier]
|
||||
private let defaults: [NSToolbarItem.Identifier]
|
||||
|
||||
/// Called with the identifiers the toolbar currently carries, whenever that set changes — the
|
||||
/// board's search item is the one consumer (`BoardToolbar`), and 03's transient-⌘F clause is the
|
||||
/// reason it exists.
|
||||
var onInstalledItemsChanged: (([NSToolbarItem.Identifier]) -> Void)?
|
||||
|
||||
/// Set while a report is already scheduled, so a customization that removes and re-adds a dozen
|
||||
/// items reports once.
|
||||
private var isReportScheduled = false
|
||||
|
||||
init(identifier: String, specs: [ToolbarItemSpec], defaults: [NSToolbarItem.Identifier]) {
|
||||
toolbar = NSToolbar(identifier: identifier)
|
||||
catalog = specs.map(\.identifier)
|
||||
self.specs = Dictionary(uniqueKeysWithValues: specs.map { ($0.identifier, $0) })
|
||||
self.defaults = defaults
|
||||
super.init()
|
||||
|
||||
toolbar.delegate = self
|
||||
// 03's three customization sentences, in three lines: the palette and its drag-rearrange,
|
||||
// the Show ▸ Icon and Text / Icon Only / Text Only popup, and the arrangement remembered
|
||||
// across launches.
|
||||
toolbar.allowsUserCustomization = true
|
||||
toolbar.allowsDisplayModeCustomization = true
|
||||
toolbar.autosavesConfiguration = true
|
||||
|
||||
trackValidationState()
|
||||
}
|
||||
|
||||
/// Re-reads every item's state and pushes it into the toolbar. Called by the observation above
|
||||
/// and worth calling directly after anything that installs the toolbar.
|
||||
func revalidate() {
|
||||
toolbar.validateVisibleItems()
|
||||
}
|
||||
|
||||
// MARK: - Installed items
|
||||
|
||||
/// Reports the toolbar's current contents, coalesced onto the next turn: the delegate callbacks
|
||||
/// fire *around* a change rather than after it (`toolbarWillAddItem` runs before the item joins
|
||||
/// `items`), so the honest answer is only available once the run loop has come back around.
|
||||
private func scheduleInstalledItemsReport() {
|
||||
guard !isReportScheduled else { return }
|
||||
isReportScheduled = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
isReportScheduled = false
|
||||
onInstalledItemsChanged?(toolbar.items.map(\.itemIdentifier))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports the contents now that the toolbar is on a window — the initial answer, which no
|
||||
/// delegate callback provides when the saved configuration happens to be empty.
|
||||
func reportInstalledItems() {
|
||||
scheduleInstalledItemsReport()
|
||||
}
|
||||
|
||||
// MARK: - NSToolbarDelegate
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
catalog + [.space, .flexibleSpace]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
defaults
|
||||
}
|
||||
|
||||
func toolbar(
|
||||
_ toolbar: NSToolbar,
|
||||
itemForItemIdentifier identifier: NSToolbarItem.Identifier,
|
||||
willBeInsertedIntoToolbar flag: Bool
|
||||
) -> NSToolbarItem? {
|
||||
guard let spec = specs[identifier] else { return nil }
|
||||
return makeItem(spec, willBeInsertedIntoToolbar: flag)
|
||||
}
|
||||
|
||||
func toolbarWillAddItem(_ notification: Notification) {
|
||||
scheduleInstalledItemsReport()
|
||||
}
|
||||
|
||||
func toolbarDidRemoveItem(_ notification: Notification) {
|
||||
scheduleInstalledItemsReport()
|
||||
}
|
||||
|
||||
// MARK: - Item construction
|
||||
|
||||
private func makeItem(_ spec: ToolbarItemSpec, willBeInsertedIntoToolbar: Bool) -> NSToolbarItem {
|
||||
switch spec.behavior {
|
||||
case .button:
|
||||
return makeButtonItem(spec)
|
||||
case .toggle:
|
||||
return makeToggleItem(spec)
|
||||
case let .responderAction(selector):
|
||||
return makeResponderItem(spec, selector: selector)
|
||||
case let .control(width, make):
|
||||
return makeControlItem(spec, width: width, view: make(willBeInsertedIntoToolbar))
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain bordered item, validated against its menu row's predicate.
|
||||
private func makeButtonItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
|
||||
let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
|
||||
decorate(item, with: spec)
|
||||
item.isBordered = true
|
||||
item.target = self
|
||||
item.action = #selector(itemFired(_:))
|
||||
item.onValidate = { [weak item] in
|
||||
item?.isEnabled = spec.isEnabled
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
/// **Undo and Redo**: no target, so AppKit resolves and validates the action up the responder
|
||||
/// chain — the same lookup their menu rows use, which is the whole of "matching their menu
|
||||
/// items" (03 ▸ Toolbar). Deliberately *not* a `ValidatingToolbarItem`: the default validation
|
||||
/// is precisely the behaviour wanted here.
|
||||
private func makeResponderItem(_ spec: ToolbarItemSpec, selector: Selector) -> NSToolbarItem {
|
||||
let item = NSToolbarItem(itemIdentifier: spec.identifier)
|
||||
decorate(item, with: spec)
|
||||
item.isBordered = true
|
||||
item.target = nil
|
||||
item.action = selector
|
||||
return item
|
||||
}
|
||||
|
||||
/// A toggle button showing on-state. A hosted `NSButton` rather than a plain item because
|
||||
/// `NSToolbarItem` has no state of its own, and 03 asks for one explicitly ("Show Trash (toggle
|
||||
/// state matching the View menu checkmark)", "Edit Body is a single toggle button (on-state in
|
||||
/// Edit)").
|
||||
private func makeToggleItem(_ spec: ToolbarItemSpec) -> NSToolbarItem {
|
||||
let button = NSButton(frame: NSRect(x: 0, y: 0, width: 38, height: 24))
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.bezelStyle = .toolbar
|
||||
button.title = ""
|
||||
button.imagePosition = .imageOnly
|
||||
if let symbol = spec.symbol {
|
||||
button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
|
||||
}
|
||||
button.identifier = NSUserInterfaceItemIdentifier(spec.identifier.rawValue)
|
||||
button.target = self
|
||||
button.action = #selector(toggleFired(_:))
|
||||
button.state = spec.isOn == true ? .on : .off
|
||||
// A custom view carries its own accessibility, where a plain item inherits the toolbar
|
||||
// item's label (10-accessibility.md: every control is named).
|
||||
button.setAccessibilityLabel(spec.label)
|
||||
button.toolTip = spec.label
|
||||
|
||||
let item = ValidatingToolbarItem(itemIdentifier: spec.identifier)
|
||||
decorate(item, with: spec)
|
||||
item.view = button
|
||||
// A custom-view item is blank in the overflow menu without this, and the overflow is one of
|
||||
// 03's clauses ("the system overflow").
|
||||
item.menuFormRepresentation = menuFormRepresentation(for: spec)
|
||||
item.onValidate = { [weak button] in
|
||||
button?.isEnabled = spec.isEnabled
|
||||
button?.state = spec.isOn == true ? .on : .off
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
/// The board's search field, hosted. Sized rather than flexible, matching the width the field
|
||||
/// shipped with in m5.
|
||||
private func makeControlItem(_ spec: ToolbarItemSpec, width: CGFloat, view: NSView) -> NSToolbarItem {
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.widthAnchor.constraint(equalToConstant: width).isActive = true
|
||||
|
||||
let item = NSToolbarItem(itemIdentifier: spec.identifier)
|
||||
decorate(item, with: spec)
|
||||
item.view = view
|
||||
// No menu form representation, deliberately: a search field in the overflow *menu* is a
|
||||
// field nobody can type into, so AppKit's generated row — label, no action, disabled — is
|
||||
// the honest presentation. ⌘F covers that window: an installed field that cannot take the
|
||||
// keyboard falls through to the transient strip (`BoardSearchPresentation.focusField`).
|
||||
// The high priority keeps the board's one default item out of the overflow to begin with.
|
||||
item.visibilityPriority = .high
|
||||
return item
|
||||
}
|
||||
|
||||
/// The three strings every item carries: the toolbar label, the palette label (the same string —
|
||||
/// one vocabulary), and the tooltip, which is what a user of an icon-only toolbar reads.
|
||||
private func decorate(_ item: NSToolbarItem, with spec: ToolbarItemSpec) {
|
||||
item.label = spec.label
|
||||
item.paletteLabel = spec.label
|
||||
item.toolTip = spec.label
|
||||
if let symbol = spec.symbol {
|
||||
item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: spec.label)
|
||||
}
|
||||
}
|
||||
|
||||
private func menuFormRepresentation(for spec: ToolbarItemSpec) -> NSMenuItem {
|
||||
let menuItem = NSMenuItem(title: spec.label, action: #selector(menuFired(_:)), keyEquivalent: "")
|
||||
menuItem.target = self
|
||||
menuItem.representedObject = spec.identifier.rawValue
|
||||
return menuItem
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func itemFired(_ sender: NSToolbarItem) {
|
||||
specs[sender.itemIdentifier]?.activate()
|
||||
}
|
||||
|
||||
@objc private func toggleFired(_ sender: NSButton) {
|
||||
guard let raw = sender.identifier?.rawValue else { return }
|
||||
specs[NSToolbarItem.Identifier(raw)]?.activate()
|
||||
// The state is the model's, never the click's: a toggle whose setter refuses (a validation
|
||||
// failure keeping source mode open, say) must not look like it succeeded.
|
||||
revalidate()
|
||||
}
|
||||
|
||||
@objc private func menuFired(_ sender: NSMenuItem) {
|
||||
guard let raw = sender.representedObject as? String else { return }
|
||||
specs[NSToolbarItem.Identifier(raw)]?.activate()
|
||||
revalidate()
|
||||
}
|
||||
|
||||
/// The overflow menu's copy of an item validates like the item itself — including the checkmark,
|
||||
/// which is where a toggle's on-state goes when the menu is its face (03 ▸ Toolbar: "the system
|
||||
/// overflow").
|
||||
@objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
guard let raw = menuItem.representedObject as? String,
|
||||
let spec = specs[NSToolbarItem.Identifier(raw)]
|
||||
else { return true }
|
||||
if let isOn = spec.isOn {
|
||||
menuItem.state = isOn ? .on : .off
|
||||
}
|
||||
return spec.isEnabled
|
||||
}
|
||||
|
||||
// MARK: - Observed validation
|
||||
|
||||
/// Re-arms itself on every change: `withObservationTracking` is one-shot by design, so the
|
||||
/// tracking closure reads every predicate again after each notification and starts a fresh
|
||||
/// observation over whatever it read this time.
|
||||
private func trackValidationState() {
|
||||
withObservationTracking {
|
||||
for spec in specs.values {
|
||||
_ = spec.isEnabled
|
||||
_ = spec.isOn
|
||||
}
|
||||
} onChange: { [weak self] in
|
||||
// The notification arrives *before* the change lands, so the re-read is deferred by a
|
||||
// turn — which is also what puts it back on the main actor.
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.revalidate()
|
||||
self.trackValidationState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The validated item
|
||||
|
||||
/// An `NSToolbarItem` whose validation is a closure over the predicate its menu row uses.
|
||||
///
|
||||
/// `NSToolbarItem`'s own `validate()` does nothing for an item with a custom view and asks the
|
||||
/// target for `validateToolbarItem:` otherwise; overriding it outright is what lets one mechanism
|
||||
/// serve both kinds — and what lets the two toggle-state reads (enabled, on) happen in the same
|
||||
/// pass, so an item can never show one of them stale.
|
||||
final class ValidatingToolbarItem: NSToolbarItem {
|
||||
|
||||
var onValidate: (() -> Void)?
|
||||
|
||||
override func validate() {
|
||||
onValidate?()
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,14 @@ struct KanbanApp: App {
|
||||
FindSteppingCommands()
|
||||
}
|
||||
|
||||
// The system's own toolbar rows — Show/Hide Toolbar and **Customize Toolbar…**, which
|
||||
// 11-command-nexus.md files under Standard macOS furniture ("Customize Toolbar… per system
|
||||
// convention (03)"). They are the platform's, spelled by the platform: both are nil-target
|
||||
// AppKit actions the key window's toolbar answers, so they validate per window (disabled on
|
||||
// welcome, live on the board and card windows) with nothing of ours in between. The
|
||||
// right-click ▸ Customize Toolbar… path 03 names is AppKit's too, and needs no row at all.
|
||||
ToolbarCommands()
|
||||
|
||||
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
||||
// items live in — which is where 11-command-nexus.md files Show Trash. A divider separates it
|
||||
// from the card window's three view-state rows below: one board-scoped toggle, then a
|
||||
|
||||
@@ -304,11 +304,10 @@ struct BoardCreationCommands: View {
|
||||
|
||||
var body: some View {
|
||||
Button("New Card") {
|
||||
guard let store, let target = newCardTarget else { return }
|
||||
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
||||
store?.beginNewCard()
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
.disabled(newCardTarget == nil)
|
||||
.disabled(store?.newCardTarget == nil)
|
||||
|
||||
Button("New Lane") {
|
||||
store?.createLane()
|
||||
@@ -316,17 +315,32 @@ struct BoardCreationCommands: View {
|
||||
.keyboardShortcut("n", modifiers: [.shift, .command])
|
||||
.disabled(store?.acceptsBoardMutations != true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses
|
||||
/// writes, an inline editor holding the keyboard, or a board with no lanes.
|
||||
private var newCardTarget: NewCardTarget.Resolution? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
extension BoardStore {
|
||||
|
||||
/// Where ⌘N would file a card, or `nil` when it cannot — a board that refuses writes, an inline
|
||||
/// editor holding the keyboard, or a board with no lanes.
|
||||
///
|
||||
/// On the store rather than private to the menu row because **the toolbar's New Card item is the
|
||||
/// same command** (03-board-ui.md ▸ Toolbar: toolbar items mirror menu commands), and a second
|
||||
/// derivation of this rule would be a second chance to disagree with it — the same reason the row
|
||||
/// itself uses one answer for both its action and its validation.
|
||||
var newCardTarget: NewCardTarget.Resolution? {
|
||||
guard acceptsBoardMutations else { return nil }
|
||||
return NewCardTarget.resolve(
|
||||
selection: store.selection,
|
||||
lastActiveLaneID: store.transient.lastActiveLaneID,
|
||||
snapshot: store.snapshot
|
||||
selection: selection,
|
||||
lastActiveLaneID: transient.lastActiveLaneID,
|
||||
snapshot: snapshot
|
||||
)
|
||||
}
|
||||
|
||||
/// ⌘N's action — opening the new-card placeholder wherever `newCardTarget` says. Shared with the
|
||||
/// toolbar item that mirrors the row.
|
||||
func beginNewCard() {
|
||||
guard let target = newCardTarget else { return }
|
||||
transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Board Info
|
||||
|
||||
@@ -4,8 +4,9 @@ import SwiftUI
|
||||
|
||||
// MARK: - The window's search field, as a handle
|
||||
|
||||
/// The board window's search field, reduced to the three things anything outside it needs to know:
|
||||
/// whether it holds the keyboard, how to give it the keyboard, and how to give the keyboard back.
|
||||
/// The board window's search field, reduced to the things anything outside it needs to know:
|
||||
/// whether it holds the keyboard, where it currently lives, how to give it the keyboard, and how to
|
||||
/// give the keyboard back.
|
||||
///
|
||||
/// `BoardInfoPresentation`'s sibling in every respect — one per window, `@State` in
|
||||
/// `BoardWindowHost`, published through the focus system so a *menu item* can reach the frontmost
|
||||
@@ -23,6 +24,15 @@ import SwiftUI
|
||||
/// **It is not `isEditingInline`, and must never become it** (04 § Search, settled): "the field is a
|
||||
/// *control*, not a content editor — the focused-editor lockdown does not apply". Board commands
|
||||
/// stay enabled and act on the selection while a query is being typed, ⌘N included.
|
||||
///
|
||||
/// ### The field has two homes, and ⌘F knows which one it is in
|
||||
///
|
||||
/// The toolbar item is the field's home and its shipped default (03-board-ui.md ▸ Toolbar: "the
|
||||
/// search field, nothing else — trailing, the one default item"). But the toolbar is customizable,
|
||||
/// so the item can be *removed*, and 03 is explicit about what happens then: "⌘F always summons
|
||||
/// search: with the field removed from the toolbar, invoking it surfaces the field transiently until
|
||||
/// the search clears." That is the whole of `invokeSearch()` below — one decision, taken against
|
||||
/// `isInstalledInToolbar`, which the toolbar controller keeps current.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSearchPresentation {
|
||||
@@ -34,15 +44,122 @@ final class BoardSearchPresentation {
|
||||
/// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F.
|
||||
var isFocused = false
|
||||
|
||||
/// Makes the field first responder — Edit ▸ Find's whole behaviour. `nil` until the field has
|
||||
/// been made, which is also exactly when ⌘F has nothing to focus.
|
||||
var focusField: (() -> Void)?
|
||||
/// Whether the toolbar currently carries the search item — the field's home.
|
||||
///
|
||||
/// Kept current by `BoardToolbar`, which reads the toolbar's contents on every configuration
|
||||
/// change. It starts `true` because that is the shipped default; a saved configuration without
|
||||
/// the item corrects it as soon as the toolbar joins the window.
|
||||
var isInstalledInToolbar = true
|
||||
|
||||
/// Whether the transient host is on screen — ⌘F's fallback when the item has been removed
|
||||
/// (03-board-ui.md ▸ Toolbar). `BoardWindowHost` renders it; nothing else may set it.
|
||||
private(set) var isTransient = false
|
||||
|
||||
/// Set when the transient host was raised by a ⌘F that had no field to focus yet — the field
|
||||
/// arrives one SwiftUI update later, and claims the keyboard when it does.
|
||||
private var wantsFocusOnAppear = false
|
||||
|
||||
/// Makes the toolbar's field first responder, **answering whether it could**. `nil` until that
|
||||
/// field has been made, which is also exactly when the toolbar has no search item to focus.
|
||||
///
|
||||
/// The answer matters because an installed item is not always a reachable one: pushed into the
|
||||
/// system overflow by a narrow window, the field is in no window and cannot take the keyboard.
|
||||
/// "⌘F always summons search" (03-board-ui.md ▸ Toolbar), so that case falls through to the
|
||||
/// transient host exactly as a removed item does — an item the user cannot type into is a
|
||||
/// removal by another name.
|
||||
var focusField: (() -> Bool)?
|
||||
|
||||
/// Makes the *transient* field first responder — the same job, one row lower.
|
||||
var focusTransientField: (() -> Void)?
|
||||
|
||||
/// Returns the keyboard to the lane strip — **Escape's second step** in an empty field
|
||||
/// (04 § Search: "in an empty field it returns focus to the board"). Filled in by `BoardView`,
|
||||
/// which owns the strip's `@FocusState`; the field cannot do this itself, because resigning
|
||||
/// first responder would leave the window focused and the board's grammar keys dead.
|
||||
var focusBoard: (() -> Void)?
|
||||
|
||||
// MARK: - ⌘F
|
||||
|
||||
/// What ⌘F does, given where the field lives right now — the decision, as a value, so 03's
|
||||
/// transient clause is testable without a toolbar.
|
||||
enum Invocation: Equatable {
|
||||
/// The item is installed: focus it, which is all ⌘F ever meant before the toolbar became
|
||||
/// customizable.
|
||||
case focusToolbarField
|
||||
/// The item is gone and the transient host is not up yet: raise it, then focus.
|
||||
case surfaceTransiently
|
||||
/// The transient host is already up (a second ⌘F, or one during an active search): just
|
||||
/// focus it. Re-raising would be a no-op the user could not tell from a re-focus, but
|
||||
/// keeping it separate is what makes "surfacing" mean the transition and not the state.
|
||||
case focusTransientField
|
||||
}
|
||||
|
||||
static func invocation(isInstalledInToolbar: Bool, isTransient: Bool) -> Invocation {
|
||||
if isInstalledInToolbar { return .focusToolbarField }
|
||||
return isTransient ? .focusTransientField : .surfaceTransiently
|
||||
}
|
||||
|
||||
/// **Whether the transient host has earned its place** — 03's "until the search clears", read
|
||||
/// honestly: a query still filtering the board keeps it, and so does the keyboard being in it.
|
||||
///
|
||||
/// The focus half is not a decoration. Without it, typing a query and deleting it back to empty
|
||||
/// would yank the field out from under the caret mid-edit; with it, the surface survives exactly
|
||||
/// as long as the user is still searching, and Escape's staged exit (clear, then hand the
|
||||
/// keyboard back) dismisses it on the second press, which is the same shape the field's Escape
|
||||
/// already has.
|
||||
static func transientPersists(query: String, isFocused: Bool) -> Bool {
|
||||
!query.isEmpty || isFocused
|
||||
}
|
||||
|
||||
/// Edit ▸ Find's whole behaviour on a board window.
|
||||
func invokeSearch() {
|
||||
switch Self.invocation(isInstalledInToolbar: isInstalledInToolbar, isTransient: isTransient) {
|
||||
case .focusToolbarField:
|
||||
// A field in the system overflow answers `false` — see `focusField`.
|
||||
if focusField?() != true { surfaceTransiently() }
|
||||
case .focusTransientField:
|
||||
focusTransientField?()
|
||||
case .surfaceTransiently:
|
||||
surfaceTransiently()
|
||||
}
|
||||
}
|
||||
|
||||
private func surfaceTransiently() {
|
||||
// The strip joins and leaves the window's layout, so it animates in the structural voice —
|
||||
// with Reduce Motion asked of AppKit rather than of the environment, because the caller is a
|
||||
// menu command whose content is built outside any rendered hierarchy
|
||||
// (`Motion.prefersReducedMotion`).
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
isTransient = true
|
||||
}
|
||||
// The host renders the field on the next update, so there is usually nothing to focus yet;
|
||||
// the flag is claimed by the field as it appears.
|
||||
if let focusTransientField {
|
||||
focusTransientField()
|
||||
} else {
|
||||
wantsFocusOnAppear = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Claimed once, by the transient field as it arrives.
|
||||
func consumeFocusOnAppear() -> Bool {
|
||||
defer { wantsFocusOnAppear = false }
|
||||
return wantsFocusOnAppear
|
||||
}
|
||||
|
||||
/// Takes the transient host back down once the search has cleared. Called by the host on every
|
||||
/// query and focus change; a no-op whenever the field is in the toolbar, where there is no
|
||||
/// transient host to take down.
|
||||
func dismissTransientIfCleared(query: String) {
|
||||
guard isTransient, !Self.transientPersists(query: query, isFocused: isFocused) else { return }
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
isTransient = false
|
||||
}
|
||||
wantsFocusOnAppear = false
|
||||
// The field goes with the strip; a stale handle would aim ⌘F at a view that has left the
|
||||
// window, and the next surfacing makes a new one.
|
||||
focusTransientField = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused board window's search field, beside `FocusedValues.boardStore` and
|
||||
@@ -71,14 +188,12 @@ extension FocusedValues {
|
||||
///
|
||||
/// **Validation is scope and nothing else**, `BoardInfoCommand`'s rule for its reason: searching is
|
||||
/// not a mutation, so neither the read-only lock nor the focused-editor rule closes it. A board
|
||||
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05, m6), and
|
||||
/// with no board in front both focused values are absent, which is the disable.
|
||||
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05), and with
|
||||
/// no board in front both focused values are absent, which is the disable.
|
||||
///
|
||||
// m6-toolbar: "removed from the toolbar, ⌘F surfaces it transiently until the search clears"
|
||||
// (03-board-ui.md ▸ Toolbar). That belongs to the toolbar-customization card, which is what first
|
||||
// makes removal possible: this item's action becomes "surface the field if it is not installed,
|
||||
// then focus it", and the transient host is the thing m6 adds. Until then the field is always in
|
||||
// the toolbar and focusing it is the whole of ⌘F.
|
||||
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
|
||||
/// toolbar's field to exist: with the item removed, ⌘F raises the transient host instead
|
||||
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
|
||||
struct FindCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@@ -97,17 +212,19 @@ struct FindCommand: View {
|
||||
if let findInText {
|
||||
findInText()
|
||||
} else {
|
||||
search?.focusField?()
|
||||
search?.invokeSearch()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
.disabled(findInText == nil && (store == nil || search?.focusField == nil))
|
||||
.disabled(findInText == nil && (store == nil || search == nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The field
|
||||
|
||||
/// The board toolbar's search field — an `NSSearchField`, hosted.
|
||||
/// The board's search field — an `NSSearchField`, made and wired in one place for its **two homes**:
|
||||
/// the toolbar item that is its default (`BoardToolbar`) and the transient host ⌘F raises when that
|
||||
/// item has been removed (`BoardSearchBar`).
|
||||
///
|
||||
/// ### Why AppKit and not `.searchable`
|
||||
///
|
||||
@@ -134,6 +251,13 @@ struct FindCommand: View {
|
||||
/// (`SearchFilter`) — no debounce, no commit step. The predicate is pure and the boards are one
|
||||
/// folder deep, so the honest cost of a keystroke is one pass over the snapshot.
|
||||
///
|
||||
/// ### The store is the truth, in both directions
|
||||
///
|
||||
/// A query cleared by Escape or by a card's creation has to reach the control, and in the toolbar
|
||||
/// there is no SwiftUI update pass to carry it — so the controller *observes* `searchQuery` and
|
||||
/// writes it back into the field. The guard against re-assigning the user's own text is not an
|
||||
/// optimisation: assigning `stringValue` resets the selection and the insertion point.
|
||||
///
|
||||
/// ### What nothing here does, and that is the point
|
||||
///
|
||||
/// **Losing focus does not clear the query.** "Tab is the keep-filter path: plain key-view traversal
|
||||
@@ -145,95 +269,142 @@ struct FindCommand: View {
|
||||
/// **Nothing sets `isEditingInline`.** The field is a control, so the focused-editor lockdown stays
|
||||
/// off and board menu commands keep acting on the selection, ⌘N included. The one narrow exception
|
||||
/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`).
|
||||
struct BoardSearchField: NSViewRepresentable {
|
||||
@MainActor
|
||||
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
/// Which of the field's two homes this one is — the only thing that differs between them, and it
|
||||
/// differs in exactly one place: which focus handle the presentation gets.
|
||||
enum Home {
|
||||
case toolbar
|
||||
case transient
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
private let store: BoardStore
|
||||
private let presentation: BoardSearchPresentation
|
||||
|
||||
/// **Weak**: the field owns this controller (`FocusReportingSearchField.controller`), so the pair
|
||||
/// lives exactly as long as whoever holds the field — a toolbar item, or a SwiftUI view.
|
||||
private weak var field: FocusReportingSearchField?
|
||||
|
||||
/// Makes the field, wires it, and hands it back. The caller owns the result and, through it,
|
||||
/// everything here.
|
||||
static func makeField(
|
||||
store: BoardStore,
|
||||
presentation: BoardSearchPresentation,
|
||||
home: Home
|
||||
) -> NSSearchField {
|
||||
let field = FocusReportingSearchField()
|
||||
let controller = BoardSearchFieldController(store: store, presentation: presentation, field: field)
|
||||
field.controller = controller
|
||||
// The delegate and nothing else: the field's *action* is deliberately unwired, because an
|
||||
// action fires on submission and this filter has no submission. Every keystroke arrives as
|
||||
// `controlTextDidChange`, which is also how the stock cancel button reaches the store — it
|
||||
// clears the text, so it is Escape's first step arriving as an ordinary change to "".
|
||||
field.delegate = context.coordinator
|
||||
field.delegate = controller
|
||||
field.placeholderString = "Search"
|
||||
field.stringValue = store.searchQuery
|
||||
field.onFocusChange = { [presentation] focused in
|
||||
presentation.isFocused = focused
|
||||
}
|
||||
// The handle ⌘F pulls. Held weakly through the view's own lifetime by capturing the field
|
||||
// itself; the presentation outlives neither the window nor the field.
|
||||
presentation.focusField = { [weak field] in
|
||||
guard let field, let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
|
||||
// The handle ⌘F pulls, filed under this field's home. Held weakly through the field, which
|
||||
// outlives neither its window nor its item.
|
||||
switch home {
|
||||
case .toolbar:
|
||||
presentation.focusField = { [weak field] in
|
||||
guard let field, let window = field.window else { return false }
|
||||
return window.makeFirstResponder(field)
|
||||
}
|
||||
case .transient:
|
||||
presentation.focusTransientField = { [weak field] in
|
||||
guard let field, let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
func updateNSView(_ field: NSSearchField, context: Context) {
|
||||
context.coordinator.owner = self
|
||||
// The store is the truth: a query cleared by Escape or by a card's creation has to reach the
|
||||
// control, and the guard keeps the user's own typing from being re-assigned under the caret
|
||||
// (which would reset the selection and the insertion point on every keystroke).
|
||||
if field.stringValue != store.searchQuery {
|
||||
field.stringValue = store.searchQuery
|
||||
}
|
||||
/// An inert field for the customization palette's copy of the search item: it looks like the
|
||||
/// real one and does nothing, because a palette item that wrote to the store or claimed ⌘F's
|
||||
/// handle would be a second live field.
|
||||
static func makePaletteField() -> NSSearchField {
|
||||
let field = NSSearchField()
|
||||
field.placeholderString = "Search"
|
||||
field.isEnabled = false
|
||||
return field
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(owner: self)
|
||||
private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
|
||||
self.store = store
|
||||
self.presentation = presentation
|
||||
self.field = field
|
||||
super.init()
|
||||
trackQuery()
|
||||
}
|
||||
|
||||
/// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own.
|
||||
final class Coordinator: NSObject, NSSearchFieldDelegate {
|
||||
/// Puts the store's query back into the control when something other than typing changed it.
|
||||
private func syncFromStore() {
|
||||
guard let field, field.stringValue != store.searchQuery else { return }
|
||||
field.stringValue = store.searchQuery
|
||||
}
|
||||
|
||||
var owner: BoardSearchField
|
||||
|
||||
init(owner: BoardSearchField) {
|
||||
self.owner = owner
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSSearchField else { return }
|
||||
owner.store.searchQuery = field.stringValue
|
||||
}
|
||||
|
||||
func controlTextDidEndEditing(_ notification: Notification) {
|
||||
owner.presentation.isFocused = false
|
||||
}
|
||||
|
||||
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
|
||||
///
|
||||
/// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the
|
||||
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
|
||||
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
|
||||
/// same staircase: with board focus and an active search, Escape clears the search).
|
||||
/// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never
|
||||
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
|
||||
/// consumed here and the board never sees it.
|
||||
///
|
||||
/// Everything else falls through to the field editor untouched, which is the whole of "stock
|
||||
/// `NSSearchField` behavior, no pass-throughs".
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
switch selector {
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
if owner.store.searchQuery.isEmpty {
|
||||
owner.presentation.focusBoard?()
|
||||
} else {
|
||||
owner.store.clearSearch()
|
||||
control.stringValue = ""
|
||||
}
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
/// Re-arms itself after every change, `WindowToolbarController.trackValidationState`'s shape and
|
||||
/// for its reason: `withObservationTracking` is one-shot, and the field outlives any single
|
||||
/// notification. It stops for good when the field has gone.
|
||||
private func trackQuery() {
|
||||
withObservationTracking {
|
||||
_ = store.searchQuery
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.field != nil else { return }
|
||||
self.syncFromStore()
|
||||
self.trackQuery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSSearchFieldDelegate
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSSearchField else { return }
|
||||
store.searchQuery = field.stringValue
|
||||
}
|
||||
|
||||
func controlTextDidEndEditing(_ notification: Notification) {
|
||||
presentation.isFocused = false
|
||||
}
|
||||
|
||||
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
|
||||
///
|
||||
/// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the
|
||||
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
|
||||
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
|
||||
/// same staircase: with board focus and an active search, Escape clears the search).
|
||||
/// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never
|
||||
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
|
||||
/// consumed here and the board never sees it.
|
||||
///
|
||||
/// Everything else falls through to the field editor untouched, which is the whole of "stock
|
||||
/// `NSSearchField` behavior, no pass-throughs".
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
switch selector {
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
if store.searchQuery.isEmpty {
|
||||
presentation.focusBoard?()
|
||||
} else {
|
||||
store.clearSearch()
|
||||
control.stringValue = ""
|
||||
}
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `NSSearchField` that says when it takes the keyboard.
|
||||
/// An `NSSearchField` that says when it takes the keyboard, and keeps its controller alive.
|
||||
///
|
||||
/// The gain is reported here rather than through `controlTextDidBeginEditing` because that
|
||||
/// notification is about an *edit session*, and the caret-chord rule is about focus: a field the
|
||||
@@ -241,13 +412,75 @@ struct BoardSearchField: NSViewRepresentable {
|
||||
/// is the delegate's `controlTextDidEndEditing`, which fires when the field editor goes — the
|
||||
/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never
|
||||
/// reaches this class.
|
||||
private final class FocusReportingSearchField: NSSearchField {
|
||||
///
|
||||
/// `controller` is the one strong link in the pair: `NSControl.delegate` is weak, and a controller
|
||||
/// nobody held would be gone before the first keystroke.
|
||||
final class FocusReportingSearchField: NSSearchField {
|
||||
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
var controller: BoardSearchFieldController?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { onFocusChange?(true) }
|
||||
return accepted
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The transient host
|
||||
|
||||
/// The search field one row below its home — what ⌘F raises when the toolbar item has been removed
|
||||
/// (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar, invoking it surfaces the
|
||||
/// field transiently until the search clears").
|
||||
///
|
||||
/// **A strip in the window's content, not an item pushed back into the toolbar.** Re-inserting the
|
||||
/// item would fight the thing the user just did — the arrangement is theirs, and it is autosaved —
|
||||
/// and would leave the Customize sheet describing a toolbar that is about to change under it. A
|
||||
/// find-bar-shaped strip below the title bar is the platform's own answer for a search surface that
|
||||
/// comes and goes, and it leaves the toolbar exactly as customized.
|
||||
///
|
||||
/// It renders the *same* field as the toolbar item, made by the same factory: one implementation of
|
||||
/// Escape's staging, of the live write-through, and of the focus reporting, whichever home it is in.
|
||||
struct BoardSearchBar: View {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
BoardSearchFieldView(store: store, presentation: presentation)
|
||||
.frame(width: 220)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field, hosted for SwiftUI — the transient host's half of the two homes.
|
||||
private struct BoardSearchFieldView: NSViewRepresentable {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
BoardSearchFieldController.makeField(store: store, presentation: presentation, home: .transient)
|
||||
}
|
||||
|
||||
/// **Where the ⌘F that raised this bar lands.** The invocation happened one update ago, before
|
||||
/// this view existed, so the keyboard is claimed here — after SwiftUI has put the field in a
|
||||
/// window, which `makeNSView` cannot promise.
|
||||
func updateNSView(_ field: NSSearchField, context: Context) {
|
||||
guard presentation.consumeFocusOnAppear() else { return }
|
||||
Task { @MainActor in
|
||||
guard let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import AppKit
|
||||
|
||||
// MARK: - Identifiers
|
||||
|
||||
extension NSToolbarItem.Identifier {
|
||||
static let boardSearch = Self("board.search")
|
||||
static let boardNewCard = Self("board.newCard")
|
||||
static let boardNewLane = Self("board.newLane")
|
||||
static let boardUndo = Self("board.undo")
|
||||
static let boardRedo = Self("board.redo")
|
||||
static let boardShowTrash = Self("board.showTrash")
|
||||
}
|
||||
|
||||
// MARK: - The board window's toolbar
|
||||
|
||||
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
||||
///
|
||||
/// ### The default is one item, and the catalog is five more
|
||||
///
|
||||
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
|
||||
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
|
||||
///
|
||||
/// "**Catalog** (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash (toggle
|
||||
/// state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
||||
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
||||
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
||||
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
||||
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
|
||||
///
|
||||
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
|
||||
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
|
||||
///
|
||||
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
|
||||
///
|
||||
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
|
||||
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
|
||||
/// carry the same actions with the same nil target, so "the pair disabled on boards without undo …
|
||||
/// matching their menu items" (03) is not a predicate written here — it is the same validation, and
|
||||
/// on a base-edition board (no undo stack until m8 wires native undo) both are disabled for the same
|
||||
/// reason the menu rows are.
|
||||
///
|
||||
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
||||
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
||||
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
|
||||
@MainActor
|
||||
enum BoardToolbar {
|
||||
|
||||
/// Shared by every board window, which is what makes the user's arrangement the *app's* rather
|
||||
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
||||
static let identifier = "dev.rzen.indie.Kanban.board"
|
||||
|
||||
/// "The search field, nothing else — trailing, the one default item."
|
||||
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
||||
|
||||
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [ToolbarItemSpec] {
|
||||
[
|
||||
.mirroring(
|
||||
menuTitle: "New Card",
|
||||
identifier: .boardNewCard,
|
||||
symbol: "doc.badge.plus",
|
||||
behavior: .button(
|
||||
isEnabled: { [weak store] in store?.newCardTarget != nil },
|
||||
perform: { [weak store] in store?.beginNewCard() }
|
||||
)
|
||||
),
|
||||
.mirroring(
|
||||
menuTitle: "New Lane",
|
||||
identifier: .boardNewLane,
|
||||
symbol: "rectangle.stack.badge.plus",
|
||||
behavior: .button(
|
||||
isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
|
||||
perform: { [weak store] in store?.createLane() }
|
||||
)
|
||||
),
|
||||
.staticLabel(
|
||||
"Undo",
|
||||
identifier: .boardUndo,
|
||||
symbol: "arrow.uturn.backward",
|
||||
behavior: .responderAction(NSSelectorFromString("undo:"))
|
||||
),
|
||||
.staticLabel(
|
||||
"Redo",
|
||||
identifier: .boardRedo,
|
||||
symbol: "arrow.uturn.forward",
|
||||
behavior: .responderAction(NSSelectorFromString("redo:"))
|
||||
),
|
||||
.mirroring(
|
||||
menuTitle: "Show Trash",
|
||||
identifier: .boardShowTrash,
|
||||
symbol: "trash",
|
||||
behavior: .toggle(
|
||||
isEnabled: { [weak store] in store != nil },
|
||||
isOn: { [weak store] in store?.transient.isTrashVisible == true },
|
||||
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
||||
)
|
||||
),
|
||||
.staticLabel(
|
||||
"Search",
|
||||
identifier: .boardSearch,
|
||||
symbol: nil,
|
||||
behavior: .control(width: 220) { [weak store] willBeInserted in
|
||||
guard willBeInserted, let store else {
|
||||
return BoardSearchFieldController.makePaletteField()
|
||||
}
|
||||
return BoardSearchFieldController.makeField(
|
||||
store: store,
|
||||
presentation: search,
|
||||
home: .toolbar
|
||||
)
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
||||
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
||||
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
||||
static func controller(store: BoardStore, search: BoardSearchPresentation) -> WindowToolbarController {
|
||||
let controller = WindowToolbarController(
|
||||
identifier: identifier,
|
||||
specs: specs(store: store, search: search),
|
||||
defaults: defaultItems
|
||||
)
|
||||
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
||||
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
||||
}
|
||||
return controller
|
||||
}
|
||||
}
|
||||
@@ -203,36 +203,47 @@ struct ShowTrashCommand: View {
|
||||
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
|
||||
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the
|
||||
/// board it names is still right there.
|
||||
///
|
||||
/// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
|
||||
/// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle
|
||||
/// state matching the View menu checkmark"), and the consequence above has to be true of both.
|
||||
private var isVisible: Binding<Bool> {
|
||||
Binding(
|
||||
get: { store?.transient.isTrashVisible ?? false },
|
||||
set: { shown in
|
||||
guard let store else { return }
|
||||
// The re-divide is a *user-initiated structural change* — every lane compresses or
|
||||
// relaxes as the trash's one unit joins or leaves the division — so it animates in
|
||||
// the structural voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a
|
||||
// re-divide trigger", a lane add's behaviour exactly). It is also one of the few
|
||||
// structural changes that never touches disk, which is why it wears its own
|
||||
// transaction here instead of arriving through the reload seam like the rest.
|
||||
//
|
||||
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's
|
||||
// content is built outside any rendered hierarchy, where the environment's
|
||||
// accessibility values are not reliably populated (`Motion.prefersReducedMotion`).
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
store.transient.isTrashVisible = shown
|
||||
// Dropped inside the same transaction: the rows it pointed at are leaving under
|
||||
// this very animation, and a selection that cleared outside it would be the
|
||||
// highlight easing on its own — which 03 § Motion rules out ("the selection
|
||||
// highlight rides whatever transaction is active").
|
||||
if !shown, store.selection.liveness == .trashed {
|
||||
store.clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
set: { shown in store?.setTrashVisible(shown) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BoardStore {
|
||||
|
||||
/// Show/Hide Trash, with its one consequence — **the whole of the View-menu row's behaviour**,
|
||||
/// shared with the toolbar item that mirrors it.
|
||||
func setTrashVisible(_ shown: Bool) {
|
||||
// The re-divide is a *user-initiated structural change* — every lane compresses or relaxes
|
||||
// as the trash's one unit joins or leaves the division — so it animates in the structural
|
||||
// voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a
|
||||
// lane add's behaviour exactly). It is also one of the few structural changes that never
|
||||
// touches disk, which is why it wears its own transaction here instead of arriving through
|
||||
// the reload seam like the rest.
|
||||
//
|
||||
// Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content
|
||||
// is built outside any rendered hierarchy, where the environment's accessibility values are
|
||||
// not reliably populated (`Motion.prefersReducedMotion`) — and a toolbar item has no
|
||||
// environment at all.
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
transient.isTrashVisible = shown
|
||||
// Dropped inside the same transaction: the rows it pointed at are leaving under this
|
||||
// very animation, and a selection that cleared outside it would be the highlight easing
|
||||
// on its own — which 03 § Motion rules out ("the selection highlight rides whatever
|
||||
// transaction is active").
|
||||
if !shown, selection.liveness == .trashed {
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The alert
|
||||
|
||||
extension View {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -132,12 +132,15 @@ enum Motion {
|
||||
enum Appearance: Equatable {
|
||||
/// Scales up from `from` while fading in (and the reverse on the way out).
|
||||
case scaleAndFade(from: CGFloat)
|
||||
/// Slides in from `edge` while fading in, and leaves the way it came.
|
||||
case slideAndFade(from: Edge)
|
||||
/// Opacity only — 10-accessibility.md's "crossfade" variant.
|
||||
case crossfade
|
||||
|
||||
var transition: AnyTransition {
|
||||
switch self {
|
||||
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
|
||||
case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
|
||||
case .crossfade: .opacity
|
||||
}
|
||||
}
|
||||
@@ -165,6 +168,20 @@ enum Motion {
|
||||
laneAppearance(reduced: reduced).transition
|
||||
}
|
||||
|
||||
/// The transient search bar arriving and leaving — ⌘F's fallback when the search field has been
|
||||
/// removed from the toolbar (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar,
|
||||
/// invoking it surfaces the field transiently until the search clears").
|
||||
///
|
||||
/// It comes from the top edge because that is where the field lives when it is installed: the
|
||||
/// bar is the toolbar's item arriving one row lower, not a new kind of surface.
|
||||
static func transientSearchAppearance(reduced: Bool) -> Appearance {
|
||||
reduced ? .crossfade : .slideAndFade(from: .top)
|
||||
}
|
||||
|
||||
static func transientSearchTransition(reduced: Bool) -> AnyTransition {
|
||||
transientSearchAppearance(reduced: reduced).transition
|
||||
}
|
||||
|
||||
// MARK: - The AppKit face
|
||||
|
||||
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
|
||||
|
||||
Reference in New Issue
Block a user