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(AppModel.self) private var appModel
|
||||||
@Environment(\.openWindow) private var openWindow
|
@Environment(\.openWindow) private var openWindow
|
||||||
@Environment(\.dismissWindow) private var dismissWindow
|
@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
|
/// 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.
|
/// alive for exactly as long as this window exists.
|
||||||
@@ -86,6 +89,15 @@ struct BoardWindowHost: View {
|
|||||||
case let .open(store):
|
case let .open(store):
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
BannerStripView(rows: store.bannerRows) { store.banners.dismiss($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`
|
// 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*
|
// 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).
|
// window to grow at its right edge (03-board-ui.md § Lane).
|
||||||
@@ -99,21 +111,14 @@ struct BoardWindowHost: View {
|
|||||||
search: boardSearch
|
search: boardSearch
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// **The board window's toolbar: the search field, nothing else** (03-board-ui.md ▸
|
// The transient strip's two dismissal inputs (`BoardSearchPresentation
|
||||||
// Toolbar, "trailing, the one default item; the titlebar stays clean"). It is a toolbar
|
// .transientPersists`): it stays while a query is filtering the board or while the field
|
||||||
// rather than a strip inside the content because that is where 03 puts it, and it hosts
|
// holds the keyboard, and goes when neither is true.
|
||||||
// an `NSSearchField` rather than `.searchable` for the reasons `BoardSearchField`
|
.onChange(of: store.searchQuery) { _, query in
|
||||||
// records — explicit first-responder control, and stock key behaviour.
|
boardSearch.dismissTransientIfCleared(query: query)
|
||||||
//
|
}
|
||||||
// m6-toolbar: the rest of 03's toolbar story is the customization card's — the
|
.onChange(of: boardSearch.isFocused) { _, _ in
|
||||||
// Customize palette, the New Card / New Lane / Undo / Redo / Show Trash catalog, and
|
boardSearch.dismissTransientIfCleared(query: store.searchQuery)
|
||||||
// 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 board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
// "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
|
// 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(
|
windowController.installTitlebarAccessory(
|
||||||
boardInfoTitlebarAccessory(store: store, recents: appModel.styleRecents, presentation: boardInfo)
|
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
|
// 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.
|
// caller just registered against it), and the record id is what both memories are keyed on.
|
||||||
let recordID = appModel.session(for: ref.board)?.recordID
|
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
|
windowController.onAttach = { window in
|
||||||
if let recordID,
|
if let recordID,
|
||||||
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
|
let saved = appModel.boardRegistry.cardWindowFrame(id: recordID, cardID: ref.cardIdentity) {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// constructor argument.
|
/// constructor argument.
|
||||||
private var titlebarAccessory: NSTitlebarAccessoryViewController?
|
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")
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "window")
|
||||||
|
|
||||||
// MARK: Attachment
|
// MARK: Attachment
|
||||||
@@ -84,14 +90,18 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
// After `onAttach`, so placement has already happened: an accessory handed over before the
|
// 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.
|
// window existed is installed here instead, and one handed over later installs immediately.
|
||||||
addTitlebarAccessoryIfPossible()
|
addTitlebarAccessoryIfPossible()
|
||||||
|
applyToolbarIfPossible()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts the previous delegate back, and takes the titlebar accessory back out. Called when the
|
/// Puts the previous delegate back, and takes the titlebar accessory and toolbar back out.
|
||||||
/// hosting view goes away; the delegate half is a no-op if something else has since taken the
|
/// Called when the hosting view goes away; the delegate half is a no-op if something else has
|
||||||
/// delegate, because stomping a third party's would be the bug this whole file exists to avoid.
|
/// since taken the delegate, because stomping a third party's would be the bug this whole file
|
||||||
|
/// exists to avoid.
|
||||||
func detach() {
|
func detach() {
|
||||||
removeTitlebarAccessory()
|
removeTitlebarAccessory()
|
||||||
titlebarAccessory = nil
|
titlebarAccessory = nil
|
||||||
|
removeToolbar()
|
||||||
|
toolbarController = nil
|
||||||
guard let window, window.delegate === self else { return }
|
guard let window, window.delegate === self else { return }
|
||||||
window.delegate = previousDelegate
|
window.delegate = previousDelegate
|
||||||
self.window = nil
|
self.window = nil
|
||||||
@@ -127,6 +137,37 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.removeTitlebarAccessoryViewController(at: index)
|
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
|
/// 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
|
/// standard path runs — SwiftUI's own delegate gets its callbacks, tabbing behaves — with the
|
||||||
/// flag telling our own `windowShouldClose` to stand aside.
|
/// 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()
|
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
|
// 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
|
// 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
|
// 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 {
|
var body: some View {
|
||||||
Button("New Card") {
|
Button("New Card") {
|
||||||
guard let store, let target = newCardTarget else { return }
|
store?.beginNewCard()
|
||||||
store.transient.beginPlaceholder(inLane: target.laneID, after: target.anchorCardID)
|
|
||||||
}
|
}
|
||||||
.keyboardShortcut("n", modifiers: .command)
|
.keyboardShortcut("n", modifiers: .command)
|
||||||
.disabled(newCardTarget == nil)
|
.disabled(store?.newCardTarget == nil)
|
||||||
|
|
||||||
Button("New Lane") {
|
Button("New Lane") {
|
||||||
store?.createLane()
|
store?.createLane()
|
||||||
@@ -316,17 +315,32 @@ struct BoardCreationCommands: View {
|
|||||||
.keyboardShortcut("n", modifiers: [.shift, .command])
|
.keyboardShortcut("n", modifiers: [.shift, .command])
|
||||||
.disabled(store?.acceptsBoardMutations != true)
|
.disabled(store?.acceptsBoardMutations != true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Where ⌘N would file a card, or `nil` when it cannot — no focused board, a board that refuses
|
extension BoardStore {
|
||||||
/// writes, an inline editor holding the keyboard, or a board with no lanes.
|
|
||||||
private var newCardTarget: NewCardTarget.Resolution? {
|
/// Where ⌘N would file a card, or `nil` when it cannot — a board that refuses writes, an inline
|
||||||
guard let store, store.acceptsBoardMutations else { return nil }
|
/// 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(
|
return NewCardTarget.resolve(
|
||||||
selection: store.selection,
|
selection: selection,
|
||||||
lastActiveLaneID: store.transient.lastActiveLaneID,
|
lastActiveLaneID: transient.lastActiveLaneID,
|
||||||
snapshot: store.snapshot
|
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
|
// MARK: - Board Info
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - The window's search field, as a handle
|
// 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:
|
/// The board window's search field, reduced to the 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.
|
/// 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
|
/// `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
|
/// `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
|
/// **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
|
/// *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.
|
/// 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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class BoardSearchPresentation {
|
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.
|
/// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F.
|
||||||
var isFocused = false
|
var isFocused = false
|
||||||
|
|
||||||
/// Makes the field first responder — Edit ▸ Find's whole behaviour. `nil` until the field has
|
/// Whether the toolbar currently carries the search item — the field's home.
|
||||||
/// been made, which is also exactly when ⌘F has nothing to focus.
|
///
|
||||||
var focusField: (() -> Void)?
|
/// 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
|
/// 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`,
|
/// (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
|
/// 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.
|
/// first responder would leave the window focused and the board's grammar keys dead.
|
||||||
var focusBoard: (() -> Void)?
|
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
|
/// 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
|
/// **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
|
/// 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
|
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05), and with
|
||||||
/// with no board in front both focused values are absent, which is the disable.
|
/// 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"
|
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
|
||||||
// (03-board-ui.md ▸ Toolbar). That belongs to the toolbar-customization card, which is what first
|
/// toolbar's field to exist: with the item removed, ⌘F raises the transient host instead
|
||||||
// makes removal possible: this item's action becomes "surface the field if it is not installed,
|
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
|
||||||
// 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.
|
|
||||||
struct FindCommand: View {
|
struct FindCommand: View {
|
||||||
|
|
||||||
@FocusedValue(\.boardStore) private var store
|
@FocusedValue(\.boardStore) private var store
|
||||||
@@ -97,17 +212,19 @@ struct FindCommand: View {
|
|||||||
if let findInText {
|
if let findInText {
|
||||||
findInText()
|
findInText()
|
||||||
} else {
|
} else {
|
||||||
search?.focusField?()
|
search?.invokeSearch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.keyboardShortcut("f", modifiers: .command)
|
.keyboardShortcut("f", modifiers: .command)
|
||||||
.disabled(findInText == nil && (store == nil || search?.focusField == nil))
|
.disabled(findInText == nil && (store == nil || search == nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The field
|
// 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`
|
/// ### 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
|
/// (`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.
|
/// 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
|
/// ### 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
|
/// **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
|
/// **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
|
/// 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`).
|
/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`).
|
||||||
struct BoardSearchField: NSViewRepresentable {
|
@MainActor
|
||||||
|
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
|
||||||
|
|
||||||
let store: BoardStore
|
/// Which of the field's two homes this one is — the only thing that differs between them, and it
|
||||||
let presentation: BoardSearchPresentation
|
/// 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 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
|
// 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
|
// 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
|
// `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 "".
|
// 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.placeholderString = "Search"
|
||||||
|
field.stringValue = store.searchQuery
|
||||||
field.onFocusChange = { [presentation] focused in
|
field.onFocusChange = { [presentation] focused in
|
||||||
presentation.isFocused = focused
|
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.
|
// The handle ⌘F pulls, filed under this field's home. Held weakly through the field, which
|
||||||
presentation.focusField = { [weak field] in
|
// outlives neither its window nor its item.
|
||||||
guard let field, let window = field.window else { return }
|
switch home {
|
||||||
window.makeFirstResponder(field)
|
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
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateNSView(_ field: NSSearchField, context: Context) {
|
/// An inert field for the customization palette's copy of the search item: it looks like the
|
||||||
context.coordinator.owner = self
|
/// real one and does nothing, because a palette item that wrote to the store or claimed ⌘F's
|
||||||
// The store is the truth: a query cleared by Escape or by a card's creation has to reach the
|
/// handle would be a second live field.
|
||||||
// control, and the guard keeps the user's own typing from being re-assigned under the caret
|
static func makePaletteField() -> NSSearchField {
|
||||||
// (which would reset the selection and the insertion point on every keystroke).
|
let field = NSSearchField()
|
||||||
if field.stringValue != store.searchQuery {
|
field.placeholderString = "Search"
|
||||||
field.stringValue = store.searchQuery
|
field.isEnabled = false
|
||||||
}
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
|
||||||
Coordinator(owner: self)
|
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.
|
/// Puts the store's query back into the control when something other than typing changed it.
|
||||||
final class Coordinator: NSObject, NSSearchFieldDelegate {
|
private func syncFromStore() {
|
||||||
|
guard let field, field.stringValue != store.searchQuery else { return }
|
||||||
|
field.stringValue = store.searchQuery
|
||||||
|
}
|
||||||
|
|
||||||
var owner: BoardSearchField
|
/// Re-arms itself after every change, `WindowToolbarController.trackValidationState`'s shape and
|
||||||
|
/// for its reason: `withObservationTracking` is one-shot, and the field outlives any single
|
||||||
init(owner: BoardSearchField) {
|
/// notification. It stops for good when the field has gone.
|
||||||
self.owner = owner
|
private func trackQuery() {
|
||||||
}
|
withObservationTracking {
|
||||||
|
_ = store.searchQuery
|
||||||
func controlTextDidChange(_ notification: Notification) {
|
} onChange: { [weak self] in
|
||||||
guard let field = notification.object as? NSSearchField else { return }
|
Task { @MainActor in
|
||||||
owner.store.searchQuery = field.stringValue
|
guard let self, self.field != nil else { return }
|
||||||
}
|
self.syncFromStore()
|
||||||
|
self.trackQuery()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never
|
||||||
/// reaches this class.
|
/// 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 onFocusChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
var controller: BoardSearchFieldController?
|
||||||
|
|
||||||
override func becomeFirstResponder() -> Bool {
|
override func becomeFirstResponder() -> Bool {
|
||||||
let accepted = super.becomeFirstResponder()
|
let accepted = super.becomeFirstResponder()
|
||||||
if accepted { onFocusChange?(true) }
|
if accepted { onFocusChange?(true) }
|
||||||
return accepted
|
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
|
/// 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
|
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched — the
|
||||||
/// board it names is still right there.
|
/// 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> {
|
private var isVisible: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { store?.transient.isTrashVisible ?? false },
|
get: { store?.transient.isTrashVisible ?? false },
|
||||||
set: { shown in
|
set: { shown in store?.setTrashVisible(shown) }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// MARK: - The alert
|
||||||
|
|
||||||
extension View {
|
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 {
|
enum Appearance: Equatable {
|
||||||
/// Scales up from `from` while fading in (and the reverse on the way out).
|
/// Scales up from `from` while fading in (and the reverse on the way out).
|
||||||
case scaleAndFade(from: CGFloat)
|
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.
|
/// Opacity only — 10-accessibility.md's "crossfade" variant.
|
||||||
case crossfade
|
case crossfade
|
||||||
|
|
||||||
var transition: AnyTransition {
|
var transition: AnyTransition {
|
||||||
switch self {
|
switch self {
|
||||||
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
|
case let .scaleAndFade(scale): .scale(scale: scale).combined(with: .opacity)
|
||||||
|
case let .slideAndFade(edge): .move(edge: edge).combined(with: .opacity)
|
||||||
case .crossfade: .opacity
|
case .crossfade: .opacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,20 @@ enum Motion {
|
|||||||
laneAppearance(reduced: reduced).transition
|
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
|
// MARK: - The AppKit face
|
||||||
|
|
||||||
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
|
/// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import AppKit
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// Two lanes, two cards in the first — enough board for New Card to have a target.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A board with no lanes — where "card creation has no target … New Card disables via menu
|
||||||
|
/// validation until a lane exists" (04-interactions.md ▸ The map).
|
||||||
|
@MainActor
|
||||||
|
private func makeEmptyBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array where Element == ToolbarItemSpec {
|
||||||
|
@MainActor
|
||||||
|
func spec(_ identifier: NSToolbarItem.Identifier) -> ToolbarItemSpec? {
|
||||||
|
first { $0.identifier == identifier }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The vocabulary
|
||||||
|
|
||||||
|
/// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) —
|
||||||
|
/// "one vocabulary everywhere, and the customize palette self-documents against the menus".
|
||||||
|
///
|
||||||
|
/// Pinned as a function rather than trusted per item because the rule's whole point is that nobody
|
||||||
|
/// spells a second name by hand: a menu row renamed without its toolbar item is two names for one
|
||||||
|
/// function, and the palette is exactly where a user compares them.
|
||||||
|
@Suite("Toolbar ▸ the label vocabulary")
|
||||||
|
struct ToolbarVocabularyTests {
|
||||||
|
|
||||||
|
@Test("A trailing ellipsis is dropped, in either spelling")
|
||||||
|
func trailingEllipsisIsDropped() {
|
||||||
|
// 03's own example: "macOS convention: 'Add Attachment…' labels as Add Attachment".
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Add Attachment…") == "Add Attachment")
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Add Attachment...") == "Add Attachment")
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Style…") == "Style")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A title with no ellipsis is its own label, verbatim")
|
||||||
|
func plainTitlesSurviveUntouched() {
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Show Trash") == "Show Trash")
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "New Card") == "New Card")
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Raw Source") == "Raw Source")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Only a *trailing* ellipsis is a suffix; one inside the title is part of the name")
|
||||||
|
func interiorEllipsisIsNotStripped() {
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Undo Move Card…") == "Undo Move Card")
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Open… Recent") == "Open… Recent")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The space a stripped ellipsis leaves behind goes with it")
|
||||||
|
func trailingSpaceIsTrimmed() {
|
||||||
|
#expect(ToolbarVocabulary.label(menuTitle: "Empty Trash …") == "Empty Trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The board window's toolbar
|
||||||
|
|
||||||
|
/// The board toolbar's shipped defaults, its catalog, and the predicates its items mirror
|
||||||
|
/// (03-board-ui.md ▸ Toolbar).
|
||||||
|
@MainActor
|
||||||
|
@Suite("Toolbar ▸ the board window")
|
||||||
|
struct BoardToolbarTests {
|
||||||
|
|
||||||
|
@Test("The default set is the search field, trailing, and nothing else")
|
||||||
|
func defaultsAreTheSearchFieldAlone() {
|
||||||
|
// "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, so the *items* in the default set are exactly one.
|
||||||
|
#expect(BoardToolbar.defaultItems == [.flexibleSpace, .boardSearch])
|
||||||
|
#expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The catalog is 03's five commands plus the field — and the board popover is not in it")
|
||||||
|
func catalogIsTheDesignsInventory() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||||
|
|
||||||
|
// "Catalog (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash" — plus
|
||||||
|
// the search field, which is a catalog item too (a user who removes it can put it back).
|
||||||
|
#expect(specs.map(\.identifier) == [
|
||||||
|
.boardNewCard,
|
||||||
|
.boardNewLane,
|
||||||
|
.boardUndo,
|
||||||
|
.boardRedo,
|
||||||
|
.boardShowTrash,
|
||||||
|
.boardSearch,
|
||||||
|
])
|
||||||
|
// "The board popover deliberately has no toolbar item — the window-title widget is its
|
||||||
|
// committed home, and a second entry would muddy it." Absence is a settlement, so it is
|
||||||
|
// pinned by the exact-inventory assertion above and stated again here.
|
||||||
|
#expect(specs.count == 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every label is its menu row's title, and Undo/Redo keep static ones")
|
||||||
|
func labelsMatchTheMenus() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||||
|
|
||||||
|
#expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"])
|
||||||
|
// The one exception 03 names: "the Undo/Redo toolbar items keep static labels —
|
||||||
|
// NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar
|
||||||
|
// label doesn't track". They are also the two items with no action of their own: nil target,
|
||||||
|
// responder-chain selectors, "matching their menu items" by using the same lookup.
|
||||||
|
for identifier in [NSToolbarItem.Identifier.boardUndo, .boardRedo] {
|
||||||
|
let spec = try #require(specs.spec(identifier))
|
||||||
|
guard case let .responderAction(selector) = spec.behavior else {
|
||||||
|
Issue.record("\(identifier.rawValue) must reach the responder chain like its menu row")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(selector == NSSelectorFromString(spec.label.lowercased() + ":"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every item's symbol resolves on this system")
|
||||||
|
func symbolsResolve() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
||||||
|
guard let symbol = spec.symbol else { continue }
|
||||||
|
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("New Card mirrors its menu row: no lanes, no target, no item")
|
||||||
|
func newCardMirrorsItsRow() throws {
|
||||||
|
let populated = try makeBoard()
|
||||||
|
defer { populated.tearDown() }
|
||||||
|
let empty = try makeEmptyBoard()
|
||||||
|
defer { empty.tearDown() }
|
||||||
|
|
||||||
|
let store = try BoardStore(rootURL: populated.root)
|
||||||
|
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||||
|
let newCard = try #require(specs.spec(.boardNewCard))
|
||||||
|
#expect(newCard.isEnabled)
|
||||||
|
#expect(newCard.isOn == nil, "New Card is a push button, not a toggle")
|
||||||
|
|
||||||
|
let emptyStore = try BoardStore(rootURL: empty.root)
|
||||||
|
let emptySpecs = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation())
|
||||||
|
let disabled = try #require(emptySpecs.spec(.boardNewCard))
|
||||||
|
#expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item")
|
||||||
|
#expect(emptyStore.newCardTarget == nil, "one predicate, read by both")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The toolbar is customizable, and every catalog item actually builds")
|
||||||
|
func everyItemBuilds() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let search = BoardSearchPresentation()
|
||||||
|
let controller = BoardToolbar.controller(store: store, search: search)
|
||||||
|
|
||||||
|
// 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange,
|
||||||
|
// system overflow and icon/text display options".
|
||||||
|
#expect(controller.toolbar.allowsUserCustomization)
|
||||||
|
#expect(controller.toolbar.allowsDisplayModeCustomization)
|
||||||
|
#expect(controller.toolbar.autosavesConfiguration, "the arrangement is the user's, and it keeps")
|
||||||
|
|
||||||
|
let allowed = controller.toolbarAllowedItemIdentifiers(controller.toolbar)
|
||||||
|
#expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers")
|
||||||
|
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems)
|
||||||
|
|
||||||
|
for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) {
|
||||||
|
let item = try #require(controller.toolbar(
|
||||||
|
controller.toolbar,
|
||||||
|
itemForItemIdentifier: spec.identifier,
|
||||||
|
willBeInsertedIntoToolbar: true
|
||||||
|
))
|
||||||
|
#expect(item.label == spec.label)
|
||||||
|
#expect(item.paletteLabel == spec.label, "one vocabulary, in the palette too")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The toolbar's search item is the field's home; the palette's copy is inert")
|
||||||
|
func searchItemOwnsTheField() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let search = BoardSearchPresentation()
|
||||||
|
let controller = BoardToolbar.controller(store: store, search: search)
|
||||||
|
|
||||||
|
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
||||||
|
|
||||||
|
let palette = try #require(controller.toolbar(
|
||||||
|
controller.toolbar,
|
||||||
|
itemForItemIdentifier: .boardSearch,
|
||||||
|
willBeInsertedIntoToolbar: false
|
||||||
|
))
|
||||||
|
#expect(palette.view is NSSearchField)
|
||||||
|
#expect(search.focusField == nil, "a palette copy must not claim ⌘F's handle")
|
||||||
|
|
||||||
|
let installed = try #require(controller.toolbar(
|
||||||
|
controller.toolbar,
|
||||||
|
itemForItemIdentifier: .boardSearch,
|
||||||
|
willBeInsertedIntoToolbar: true
|
||||||
|
))
|
||||||
|
let field = try #require(installed.view as? NSSearchField)
|
||||||
|
#expect(search.focusField != nil, "the installed item is the field's home")
|
||||||
|
|
||||||
|
// The live field writes through per keystroke, which is the m5 contract this milestone
|
||||||
|
// moved rather than changed (`BoardSearchFieldController`).
|
||||||
|
field.stringValue = "spec"
|
||||||
|
field.delegate?.controlTextDidChange?(Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||||
|
#expect(store.searchQuery == "spec")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Show Trash is a toggle whose state is the View menu's checkmark")
|
||||||
|
func showTrashTogglesTheQuasiLane() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation())
|
||||||
|
let showTrash = try #require(specs.spec(.boardShowTrash))
|
||||||
|
|
||||||
|
#expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark")
|
||||||
|
|
||||||
|
showTrash.activate()
|
||||||
|
#expect(store.transient.isTrashVisible, "the item drives the row's own setter")
|
||||||
|
#expect(showTrash.isOn == true)
|
||||||
|
|
||||||
|
showTrash.activate()
|
||||||
|
#expect(!store.transient.isTrashVisible)
|
||||||
|
#expect(showTrash.isOn == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The card window's toolbar
|
||||||
|
|
||||||
|
/// The card toolbar's trio, and the two state clauses 03 states about it: Edit Body's on-state and
|
||||||
|
/// its raw-source disable, and Add Attachment staying live in every mode.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Toolbar ▸ the card window")
|
||||||
|
struct CardToolbarTests {
|
||||||
|
|
||||||
|
/// The three window-scoped handles a card window's toolbar reads, wired as the host wires them.
|
||||||
|
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments) {
|
||||||
|
let body = CardBodyPresentation()
|
||||||
|
let raw = CardRawSourceSession()
|
||||||
|
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
|
||||||
|
raw.apply = { _ in .applied }
|
||||||
|
let attachments = CardAttachments()
|
||||||
|
attachments.isEditable = true
|
||||||
|
attachments.cardFolder = URL(filePath: "/tmp/board/lane/card")
|
||||||
|
return (body, raw, attachments)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The default set is the whole catalog — the trio, in 03's order")
|
||||||
|
func defaultsAreTheCatalog() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
|
||||||
|
// "Card window default: Edit Body · Raw Source · Add Attachment … the catalog is the same
|
||||||
|
// trio."
|
||||||
|
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment])
|
||||||
|
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
|
||||||
|
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every item's symbol resolves on this system")
|
||||||
|
func symbolsResolve() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) {
|
||||||
|
guard let symbol = spec.symbol else { continue }
|
||||||
|
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every item of the trio actually builds, and the toolbar is customizable")
|
||||||
|
func everyItemBuilds() throws {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
|
||||||
|
#expect(controller.toolbar.allowsUserCustomization)
|
||||||
|
#expect(controller.toolbar.allowsDisplayModeCustomization)
|
||||||
|
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems)
|
||||||
|
|
||||||
|
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) {
|
||||||
|
let item = try #require(controller.toolbar(
|
||||||
|
controller.toolbar,
|
||||||
|
itemForItemIdentifier: spec.identifier,
|
||||||
|
willBeInsertedIntoToolbar: true
|
||||||
|
))
|
||||||
|
#expect(item.label == spec.label)
|
||||||
|
#expect(item.paletteLabel == spec.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Edit Body is a single toggle showing on-state in Edit")
|
||||||
|
func editBodyShowsItsMode() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
guard let editBody = specs.spec(.cardEditBody) else {
|
||||||
|
Issue.record("no Edit Body item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(editBody.isOn == false, "Preview is the window's opening mode")
|
||||||
|
|
||||||
|
editBody.activate()
|
||||||
|
#expect(body.mode == .edit, "the item drives the same flip the ⌘E row does")
|
||||||
|
#expect(editBody.isOn == true)
|
||||||
|
|
||||||
|
editBody.activate()
|
||||||
|
#expect(body.mode == .preview)
|
||||||
|
#expect(editBody.isOn == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Raw Source active disables Edit Body — the row's own predicate, mirrored")
|
||||||
|
func rawSourceDisablesEditBody() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else {
|
||||||
|
Issue.record("the card toolbar is missing an item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(editBody.isEnabled)
|
||||||
|
#expect(rawSource.isOn == false)
|
||||||
|
|
||||||
|
rawSource.activate()
|
||||||
|
|
||||||
|
#expect(raw.isActive, "the item enters source mode exactly as ⌥⌘E does")
|
||||||
|
#expect(rawSource.isOn == true, "a toggle showing on-state")
|
||||||
|
// "While source mode is active, Edit Body disables (Cancel/Apply own the exits)" — and the
|
||||||
|
// predicate is `EditBodyCommand.isEnabled`, handed to the item rather than restated.
|
||||||
|
#expect(!editBody.isEnabled)
|
||||||
|
#expect(editBody.isEnabled == EditBodyCommand.isEnabled(body: body, rawSource: raw))
|
||||||
|
|
||||||
|
raw.cancel()
|
||||||
|
#expect(editBody.isEnabled)
|
||||||
|
#expect(rawSource.isOn == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Add Attachment stays enabled in every mode, including an open raw edit")
|
||||||
|
func addAttachmentIsAlwaysAvailable() {
|
||||||
|
let (body, raw, attachments) = makeHandles()
|
||||||
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
||||||
|
guard let addAttachment = specs.spec(.cardAddAttachment) else {
|
||||||
|
Issue.record("no Add Attachment item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(addAttachment.isEnabled)
|
||||||
|
|
||||||
|
// "Add Attachment stays enabled in every mode — attachment operations never touch
|
||||||
|
// `index.md`, so they're safe alongside a raw edit" (03 ▸ Toolbar; 01-storage-format.md
|
||||||
|
// makes the same point from the stamping side).
|
||||||
|
raw.enter()
|
||||||
|
#expect(raw.isActive)
|
||||||
|
#expect(addAttachment.isEnabled)
|
||||||
|
|
||||||
|
body.setMode(.edit)
|
||||||
|
#expect(addAttachment.isEnabled)
|
||||||
|
|
||||||
|
// The lock is the row's own predicate, and the item inherits it whole.
|
||||||
|
attachments.isEditable = false
|
||||||
|
#expect(!addAttachment.isEnabled)
|
||||||
|
#expect(addAttachment.isEnabled == AddAttachmentCommand.isEnabled(attachments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ⌘F and the search field's two homes
|
||||||
|
|
||||||
|
/// "⌘F always summons search: with the field removed from the toolbar, invoking it surfaces the
|
||||||
|
/// field transiently until the search clears" (03-board-ui.md ▸ Toolbar).
|
||||||
|
///
|
||||||
|
/// The decision and the dismissal are pure functions on `BoardSearchPresentation` precisely so this
|
||||||
|
/// clause is testable without a toolbar, a window, or a first responder — none of which a unit test
|
||||||
|
/// can stand up honestly.
|
||||||
|
@MainActor
|
||||||
|
@Suite("Toolbar ▸ ⌘F's transient fallback")
|
||||||
|
struct BoardSearchSurfacingTests {
|
||||||
|
|
||||||
|
@Test("Installed, ⌘F focuses the toolbar's field; removed, it surfaces the transient one")
|
||||||
|
func invocationFollowsTheField() {
|
||||||
|
#expect(
|
||||||
|
BoardSearchPresentation.invocation(isInstalledInToolbar: true, isTransient: false)
|
||||||
|
== .focusToolbarField
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
BoardSearchPresentation.invocation(isInstalledInToolbar: true, isTransient: true)
|
||||||
|
== .focusToolbarField,
|
||||||
|
"the item is the field's home whenever it exists"
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
BoardSearchPresentation.invocation(isInstalledInToolbar: false, isTransient: false)
|
||||||
|
== .surfaceTransiently
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
BoardSearchPresentation.invocation(isInstalledInToolbar: false, isTransient: true)
|
||||||
|
== .focusTransientField,
|
||||||
|
"a second ⌘F re-focuses rather than re-surfacing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The transient surface lasts until the search clears — and never mid-edit")
|
||||||
|
func transientLifetime() {
|
||||||
|
// A query still filtering the board keeps it, focused or not: Tab is the keep-filter path,
|
||||||
|
// and the field has to stay reachable while the filter stands (04 § Search).
|
||||||
|
#expect(BoardSearchPresentation.transientPersists(query: "spec", isFocused: false))
|
||||||
|
#expect(BoardSearchPresentation.transientPersists(query: "spec", isFocused: true))
|
||||||
|
// An emptied field the user is still typing in keeps it too — otherwise deleting back to
|
||||||
|
// nothing would yank the field out from under the caret.
|
||||||
|
#expect(BoardSearchPresentation.transientPersists(query: "", isFocused: true))
|
||||||
|
// Cleared and unfocused: the search is over.
|
||||||
|
#expect(!BoardSearchPresentation.transientPersists(query: "", isFocused: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("With the item installed, ⌘F focuses it and raises nothing")
|
||||||
|
func installedFieldIsFocusedInPlace() {
|
||||||
|
let presentation = BoardSearchPresentation()
|
||||||
|
var focused = 0
|
||||||
|
presentation.focusField = {
|
||||||
|
focused += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
presentation.invokeSearch()
|
||||||
|
|
||||||
|
#expect(focused == 1)
|
||||||
|
#expect(!presentation.isTransient, "the titlebar stays the field's home")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("With the item removed, ⌘F raises the strip and the arriving field claims the keyboard")
|
||||||
|
func removedFieldSurfacesTransiently() {
|
||||||
|
let presentation = BoardSearchPresentation()
|
||||||
|
presentation.isInstalledInToolbar = false
|
||||||
|
presentation.focusField = {
|
||||||
|
Issue.record("the toolbar has no field to focus")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
presentation.invokeSearch()
|
||||||
|
|
||||||
|
#expect(presentation.isTransient)
|
||||||
|
// The field does not exist yet — the host renders it on the next update — so the focus is a
|
||||||
|
// claim the field takes as it appears, exactly once.
|
||||||
|
#expect(presentation.consumeFocusOnAppear())
|
||||||
|
#expect(!presentation.consumeFocusOnAppear())
|
||||||
|
|
||||||
|
// Now it exists: a second ⌘F focuses it in place.
|
||||||
|
var focused = 0
|
||||||
|
presentation.focusTransientField = { focused += 1 }
|
||||||
|
presentation.invokeSearch()
|
||||||
|
#expect(focused == 1)
|
||||||
|
#expect(presentation.isTransient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The strip goes when the search clears, and stays while it has not")
|
||||||
|
func transientDismissal() {
|
||||||
|
let presentation = BoardSearchPresentation()
|
||||||
|
presentation.isInstalledInToolbar = false
|
||||||
|
presentation.invokeSearch()
|
||||||
|
presentation.focusTransientField = {}
|
||||||
|
#expect(presentation.isTransient)
|
||||||
|
|
||||||
|
presentation.isFocused = true
|
||||||
|
presentation.dismissTransientIfCleared(query: "")
|
||||||
|
#expect(presentation.isTransient, "the keyboard is still in it")
|
||||||
|
|
||||||
|
presentation.isFocused = false
|
||||||
|
presentation.dismissTransientIfCleared(query: "spec")
|
||||||
|
#expect(presentation.isTransient, "the filter is still standing")
|
||||||
|
|
||||||
|
presentation.dismissTransientIfCleared(query: "")
|
||||||
|
#expect(!presentation.isTransient)
|
||||||
|
#expect(presentation.focusTransientField == nil, "the handle goes with the field")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An installed field that cannot take the keyboard falls back to the strip")
|
||||||
|
func overflowedFieldFallsBackToTheStrip() {
|
||||||
|
// The item is installed but in the system overflow, where it is in no window and cannot
|
||||||
|
// become first responder. "⌘F always summons search", so the strip stands in — an item the
|
||||||
|
// user cannot type into is a removal by another name.
|
||||||
|
let presentation = BoardSearchPresentation()
|
||||||
|
presentation.focusField = { false }
|
||||||
|
|
||||||
|
presentation.invokeSearch()
|
||||||
|
|
||||||
|
#expect(presentation.isTransient)
|
||||||
|
#expect(presentation.consumeFocusOnAppear())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A field in the toolbar has no transient surface to dismiss")
|
||||||
|
func installedFieldIgnoresDismissal() {
|
||||||
|
let presentation = BoardSearchPresentation()
|
||||||
|
#expect(presentation.isInstalledInToolbar, "the shipped default")
|
||||||
|
presentation.focusField = { true }
|
||||||
|
|
||||||
|
presentation.invokeSearch()
|
||||||
|
presentation.dismissTransientIfCleared(query: "")
|
||||||
|
|
||||||
|
#expect(!presentation.isTransient)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
|
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
|
||||||
|
|
||||||
|
- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
The Xcode project is generated — `project.yml` is the source of truth, not the `.xcodeproj`:
|
||||||
|
|||||||
Reference in New Issue
Block a user