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:
@@ -4,8 +4,9 @@ import SwiftUI
|
||||
|
||||
// MARK: - The window's search field, as a handle
|
||||
|
||||
/// The board window's search field, reduced to the three things anything outside it needs to know:
|
||||
/// whether it holds the keyboard, how to give it the keyboard, and how to give the keyboard back.
|
||||
/// The board window's search field, reduced to the things anything outside it needs to know:
|
||||
/// whether it holds the keyboard, where it currently lives, how to give it the keyboard, and how to
|
||||
/// give the keyboard back.
|
||||
///
|
||||
/// `BoardInfoPresentation`'s sibling in every respect — one per window, `@State` in
|
||||
/// `BoardWindowHost`, published through the focus system so a *menu item* can reach the frontmost
|
||||
@@ -23,6 +24,15 @@ import SwiftUI
|
||||
/// **It is not `isEditingInline`, and must never become it** (04 § Search, settled): "the field is a
|
||||
/// *control*, not a content editor — the focused-editor lockdown does not apply". Board commands
|
||||
/// stay enabled and act on the selection while a query is being typed, ⌘N included.
|
||||
///
|
||||
/// ### The field has two homes, and ⌘F knows which one it is in
|
||||
///
|
||||
/// The toolbar item is the field's home and its shipped default (03-board-ui.md ▸ Toolbar: "the
|
||||
/// search field, nothing else — trailing, the one default item"). But the toolbar is customizable,
|
||||
/// so the item can be *removed*, and 03 is explicit about what happens then: "⌘F always summons
|
||||
/// search: with the field removed from the toolbar, invoking it surfaces the field transiently until
|
||||
/// the search clears." That is the whole of `invokeSearch()` below — one decision, taken against
|
||||
/// `isInstalledInToolbar`, which the toolbar controller keeps current.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSearchPresentation {
|
||||
@@ -34,15 +44,122 @@ final class BoardSearchPresentation {
|
||||
/// what makes it true for the AppKit key-view loop's Tab traversal as well as for ⌘F.
|
||||
var isFocused = false
|
||||
|
||||
/// Makes the field first responder — Edit ▸ Find's whole behaviour. `nil` until the field has
|
||||
/// been made, which is also exactly when ⌘F has nothing to focus.
|
||||
var focusField: (() -> Void)?
|
||||
/// Whether the toolbar currently carries the search item — the field's home.
|
||||
///
|
||||
/// Kept current by `BoardToolbar`, which reads the toolbar's contents on every configuration
|
||||
/// change. It starts `true` because that is the shipped default; a saved configuration without
|
||||
/// the item corrects it as soon as the toolbar joins the window.
|
||||
var isInstalledInToolbar = true
|
||||
|
||||
/// Whether the transient host is on screen — ⌘F's fallback when the item has been removed
|
||||
/// (03-board-ui.md ▸ Toolbar). `BoardWindowHost` renders it; nothing else may set it.
|
||||
private(set) var isTransient = false
|
||||
|
||||
/// Set when the transient host was raised by a ⌘F that had no field to focus yet — the field
|
||||
/// arrives one SwiftUI update later, and claims the keyboard when it does.
|
||||
private var wantsFocusOnAppear = false
|
||||
|
||||
/// Makes the toolbar's field first responder, **answering whether it could**. `nil` until that
|
||||
/// field has been made, which is also exactly when the toolbar has no search item to focus.
|
||||
///
|
||||
/// The answer matters because an installed item is not always a reachable one: pushed into the
|
||||
/// system overflow by a narrow window, the field is in no window and cannot take the keyboard.
|
||||
/// "⌘F always summons search" (03-board-ui.md ▸ Toolbar), so that case falls through to the
|
||||
/// transient host exactly as a removed item does — an item the user cannot type into is a
|
||||
/// removal by another name.
|
||||
var focusField: (() -> Bool)?
|
||||
|
||||
/// Makes the *transient* field first responder — the same job, one row lower.
|
||||
var focusTransientField: (() -> Void)?
|
||||
|
||||
/// Returns the keyboard to the lane strip — **Escape's second step** in an empty field
|
||||
/// (04 § Search: "in an empty field it returns focus to the board"). Filled in by `BoardView`,
|
||||
/// which owns the strip's `@FocusState`; the field cannot do this itself, because resigning
|
||||
/// first responder would leave the window focused and the board's grammar keys dead.
|
||||
var focusBoard: (() -> Void)?
|
||||
|
||||
// MARK: - ⌘F
|
||||
|
||||
/// What ⌘F does, given where the field lives right now — the decision, as a value, so 03's
|
||||
/// transient clause is testable without a toolbar.
|
||||
enum Invocation: Equatable {
|
||||
/// The item is installed: focus it, which is all ⌘F ever meant before the toolbar became
|
||||
/// customizable.
|
||||
case focusToolbarField
|
||||
/// The item is gone and the transient host is not up yet: raise it, then focus.
|
||||
case surfaceTransiently
|
||||
/// The transient host is already up (a second ⌘F, or one during an active search): just
|
||||
/// focus it. Re-raising would be a no-op the user could not tell from a re-focus, but
|
||||
/// keeping it separate is what makes "surfacing" mean the transition and not the state.
|
||||
case focusTransientField
|
||||
}
|
||||
|
||||
static func invocation(isInstalledInToolbar: Bool, isTransient: Bool) -> Invocation {
|
||||
if isInstalledInToolbar { return .focusToolbarField }
|
||||
return isTransient ? .focusTransientField : .surfaceTransiently
|
||||
}
|
||||
|
||||
/// **Whether the transient host has earned its place** — 03's "until the search clears", read
|
||||
/// honestly: a query still filtering the board keeps it, and so does the keyboard being in it.
|
||||
///
|
||||
/// The focus half is not a decoration. Without it, typing a query and deleting it back to empty
|
||||
/// would yank the field out from under the caret mid-edit; with it, the surface survives exactly
|
||||
/// as long as the user is still searching, and Escape's staged exit (clear, then hand the
|
||||
/// keyboard back) dismisses it on the second press, which is the same shape the field's Escape
|
||||
/// already has.
|
||||
static func transientPersists(query: String, isFocused: Bool) -> Bool {
|
||||
!query.isEmpty || isFocused
|
||||
}
|
||||
|
||||
/// Edit ▸ Find's whole behaviour on a board window.
|
||||
func invokeSearch() {
|
||||
switch Self.invocation(isInstalledInToolbar: isInstalledInToolbar, isTransient: isTransient) {
|
||||
case .focusToolbarField:
|
||||
// A field in the system overflow answers `false` — see `focusField`.
|
||||
if focusField?() != true { surfaceTransiently() }
|
||||
case .focusTransientField:
|
||||
focusTransientField?()
|
||||
case .surfaceTransiently:
|
||||
surfaceTransiently()
|
||||
}
|
||||
}
|
||||
|
||||
private func surfaceTransiently() {
|
||||
// The strip joins and leaves the window's layout, so it animates in the structural voice —
|
||||
// with Reduce Motion asked of AppKit rather than of the environment, because the caller is a
|
||||
// menu command whose content is built outside any rendered hierarchy
|
||||
// (`Motion.prefersReducedMotion`).
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
isTransient = true
|
||||
}
|
||||
// The host renders the field on the next update, so there is usually nothing to focus yet;
|
||||
// the flag is claimed by the field as it appears.
|
||||
if let focusTransientField {
|
||||
focusTransientField()
|
||||
} else {
|
||||
wantsFocusOnAppear = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Claimed once, by the transient field as it arrives.
|
||||
func consumeFocusOnAppear() -> Bool {
|
||||
defer { wantsFocusOnAppear = false }
|
||||
return wantsFocusOnAppear
|
||||
}
|
||||
|
||||
/// Takes the transient host back down once the search has cleared. Called by the host on every
|
||||
/// query and focus change; a no-op whenever the field is in the toolbar, where there is no
|
||||
/// transient host to take down.
|
||||
func dismissTransientIfCleared(query: String) {
|
||||
guard isTransient, !Self.transientPersists(query: query, isFocused: isFocused) else { return }
|
||||
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
|
||||
isTransient = false
|
||||
}
|
||||
wantsFocusOnAppear = false
|
||||
// The field goes with the strip; a stale handle would aim ⌘F at a view that has left the
|
||||
// window, and the next surfacing makes a new one.
|
||||
focusTransientField = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The focused board window's search field, beside `FocusedValues.boardStore` and
|
||||
@@ -71,14 +188,12 @@ extension FocusedValues {
|
||||
///
|
||||
/// **Validation is scope and nothing else**, `BoardInfoCommand`'s rule for its reason: searching is
|
||||
/// not a mutation, so neither the read-only lock nor the focused-editor rule closes it. A board
|
||||
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05, m6), and
|
||||
/// with no board in front both focused values are absent, which is the disable.
|
||||
/// window is the only context it has (the card window's Edit ▸ Find is find-in-text — 05), and with
|
||||
/// no board in front both focused values are absent, which is the disable.
|
||||
///
|
||||
// m6-toolbar: "removed from the toolbar, ⌘F surfaces it transiently until the search clears"
|
||||
// (03-board-ui.md ▸ Toolbar). That belongs to the toolbar-customization card, which is what first
|
||||
// makes removal possible: this item's action becomes "surface the field if it is not installed,
|
||||
// then focus it", and the transient host is the thing m6 adds. Until then the field is always in
|
||||
// the toolbar and focusing it is the whole of ⌘F.
|
||||
/// **A board window in front is now the whole of the scope**, where m5 additionally required the
|
||||
/// toolbar's field to exist: with the item removed, ⌘F raises the transient host instead
|
||||
/// (`BoardSearchPresentation.invokeSearch`), so there is no board window where the row is dead.
|
||||
struct FindCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@@ -97,17 +212,19 @@ struct FindCommand: View {
|
||||
if let findInText {
|
||||
findInText()
|
||||
} else {
|
||||
search?.focusField?()
|
||||
search?.invokeSearch()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
.disabled(findInText == nil && (store == nil || search?.focusField == nil))
|
||||
.disabled(findInText == nil && (store == nil || search == nil))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The field
|
||||
|
||||
/// The board toolbar's search field — an `NSSearchField`, hosted.
|
||||
/// The board's search field — an `NSSearchField`, made and wired in one place for its **two homes**:
|
||||
/// the toolbar item that is its default (`BoardToolbar`) and the transient host ⌘F raises when that
|
||||
/// item has been removed (`BoardSearchBar`).
|
||||
///
|
||||
/// ### Why AppKit and not `.searchable`
|
||||
///
|
||||
@@ -134,6 +251,13 @@ struct FindCommand: View {
|
||||
/// (`SearchFilter`) — no debounce, no commit step. The predicate is pure and the boards are one
|
||||
/// folder deep, so the honest cost of a keystroke is one pass over the snapshot.
|
||||
///
|
||||
/// ### The store is the truth, in both directions
|
||||
///
|
||||
/// A query cleared by Escape or by a card's creation has to reach the control, and in the toolbar
|
||||
/// there is no SwiftUI update pass to carry it — so the controller *observes* `searchQuery` and
|
||||
/// writes it back into the field. The guard against re-assigning the user's own text is not an
|
||||
/// optimisation: assigning `stringValue` resets the selection and the insertion point.
|
||||
///
|
||||
/// ### What nothing here does, and that is the point
|
||||
///
|
||||
/// **Losing focus does not clear the query.** "Tab is the keep-filter path: plain key-view traversal
|
||||
@@ -145,95 +269,142 @@ struct FindCommand: View {
|
||||
/// **Nothing sets `isEditingInline`.** The field is a control, so the focused-editor lockdown stays
|
||||
/// off and board menu commands keep acting on the selection, ⌘N included. The one narrow exception
|
||||
/// is the caret chords, which read `BoardSearchPresentation.isFocused` (`caretChordsYield`).
|
||||
struct BoardSearchField: NSViewRepresentable {
|
||||
@MainActor
|
||||
final class BoardSearchFieldController: NSObject, NSSearchFieldDelegate {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
/// Which of the field's two homes this one is — the only thing that differs between them, and it
|
||||
/// differs in exactly one place: which focus handle the presentation gets.
|
||||
enum Home {
|
||||
case toolbar
|
||||
case transient
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
private let store: BoardStore
|
||||
private let presentation: BoardSearchPresentation
|
||||
|
||||
/// **Weak**: the field owns this controller (`FocusReportingSearchField.controller`), so the pair
|
||||
/// lives exactly as long as whoever holds the field — a toolbar item, or a SwiftUI view.
|
||||
private weak var field: FocusReportingSearchField?
|
||||
|
||||
/// Makes the field, wires it, and hands it back. The caller owns the result and, through it,
|
||||
/// everything here.
|
||||
static func makeField(
|
||||
store: BoardStore,
|
||||
presentation: BoardSearchPresentation,
|
||||
home: Home
|
||||
) -> NSSearchField {
|
||||
let field = FocusReportingSearchField()
|
||||
let controller = BoardSearchFieldController(store: store, presentation: presentation, field: field)
|
||||
field.controller = controller
|
||||
// The delegate and nothing else: the field's *action* is deliberately unwired, because an
|
||||
// action fires on submission and this filter has no submission. Every keystroke arrives as
|
||||
// `controlTextDidChange`, which is also how the stock cancel button reaches the store — it
|
||||
// clears the text, so it is Escape's first step arriving as an ordinary change to "".
|
||||
field.delegate = context.coordinator
|
||||
field.delegate = controller
|
||||
field.placeholderString = "Search"
|
||||
field.stringValue = store.searchQuery
|
||||
field.onFocusChange = { [presentation] focused in
|
||||
presentation.isFocused = focused
|
||||
}
|
||||
// The handle ⌘F pulls. Held weakly through the view's own lifetime by capturing the field
|
||||
// itself; the presentation outlives neither the window nor the field.
|
||||
presentation.focusField = { [weak field] in
|
||||
guard let field, let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
|
||||
// The handle ⌘F pulls, filed under this field's home. Held weakly through the field, which
|
||||
// outlives neither its window nor its item.
|
||||
switch home {
|
||||
case .toolbar:
|
||||
presentation.focusField = { [weak field] in
|
||||
guard let field, let window = field.window else { return false }
|
||||
return window.makeFirstResponder(field)
|
||||
}
|
||||
case .transient:
|
||||
presentation.focusTransientField = { [weak field] in
|
||||
guard let field, let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
}
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
func updateNSView(_ field: NSSearchField, context: Context) {
|
||||
context.coordinator.owner = self
|
||||
// The store is the truth: a query cleared by Escape or by a card's creation has to reach the
|
||||
// control, and the guard keeps the user's own typing from being re-assigned under the caret
|
||||
// (which would reset the selection and the insertion point on every keystroke).
|
||||
if field.stringValue != store.searchQuery {
|
||||
field.stringValue = store.searchQuery
|
||||
}
|
||||
/// An inert field for the customization palette's copy of the search item: it looks like the
|
||||
/// real one and does nothing, because a palette item that wrote to the store or claimed ⌘F's
|
||||
/// handle would be a second live field.
|
||||
static func makePaletteField() -> NSSearchField {
|
||||
let field = NSSearchField()
|
||||
field.placeholderString = "Search"
|
||||
field.isEnabled = false
|
||||
return field
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(owner: self)
|
||||
private init(store: BoardStore, presentation: BoardSearchPresentation, field: FocusReportingSearchField) {
|
||||
self.store = store
|
||||
self.presentation = presentation
|
||||
self.field = field
|
||||
super.init()
|
||||
trackQuery()
|
||||
}
|
||||
|
||||
/// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own.
|
||||
final class Coordinator: NSObject, NSSearchFieldDelegate {
|
||||
/// Puts the store's query back into the control when something other than typing changed it.
|
||||
private func syncFromStore() {
|
||||
guard let field, field.stringValue != store.searchQuery else { return }
|
||||
field.stringValue = store.searchQuery
|
||||
}
|
||||
|
||||
var owner: BoardSearchField
|
||||
|
||||
init(owner: BoardSearchField) {
|
||||
self.owner = owner
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSSearchField else { return }
|
||||
owner.store.searchQuery = field.stringValue
|
||||
}
|
||||
|
||||
func controlTextDidEndEditing(_ notification: Notification) {
|
||||
owner.presentation.isFocused = false
|
||||
}
|
||||
|
||||
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
|
||||
///
|
||||
/// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the
|
||||
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
|
||||
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
|
||||
/// same staircase: with board focus and an active search, Escape clears the search).
|
||||
/// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never
|
||||
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
|
||||
/// consumed here and the board never sees it.
|
||||
///
|
||||
/// Everything else falls through to the field editor untouched, which is the whole of "stock
|
||||
/// `NSSearchField` behavior, no pass-throughs".
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
switch selector {
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
if owner.store.searchQuery.isEmpty {
|
||||
owner.presentation.focusBoard?()
|
||||
} else {
|
||||
owner.store.clearSearch()
|
||||
control.stringValue = ""
|
||||
}
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
/// Re-arms itself after every change, `WindowToolbarController.trackValidationState`'s shape and
|
||||
/// for its reason: `withObservationTracking` is one-shot, and the field outlives any single
|
||||
/// notification. It stops for good when the field has gone.
|
||||
private func trackQuery() {
|
||||
withObservationTracking {
|
||||
_ = store.searchQuery
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self, self.field != nil else { return }
|
||||
self.syncFromStore()
|
||||
self.trackQuery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: NSSearchFieldDelegate
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSSearchField else { return }
|
||||
store.searchQuery = field.stringValue
|
||||
}
|
||||
|
||||
func controlTextDidEndEditing(_ notification: Notification) {
|
||||
presentation.isFocused = false
|
||||
}
|
||||
|
||||
/// **Escape is staged and Return is swallowed** (04-interactions.md § Search, settled).
|
||||
///
|
||||
/// - `cancelOperation:` — Escape. A non-empty field clears the query and *keeps* the
|
||||
/// keyboard; an empty one hands it back to the board. One press, one layer, which is the
|
||||
/// same shape `BoardView.handleEscape` gives the board side (and the third step of the
|
||||
/// same staircase: with board focus and an active search, Escape clears the search).
|
||||
/// - `insertNewline:` — Return. "The filter is live, there is nothing to submit — it never
|
||||
/// reaches the board's rename/create grammar." Returning `true` is that no-op: the key is
|
||||
/// consumed here and the board never sees it.
|
||||
///
|
||||
/// Everything else falls through to the field editor untouched, which is the whole of "stock
|
||||
/// `NSSearchField` behavior, no pass-throughs".
|
||||
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
||||
switch selector {
|
||||
case #selector(NSResponder.cancelOperation(_:)):
|
||||
if store.searchQuery.isEmpty {
|
||||
presentation.focusBoard?()
|
||||
} else {
|
||||
store.clearSearch()
|
||||
control.stringValue = ""
|
||||
}
|
||||
return true
|
||||
case #selector(NSResponder.insertNewline(_:)):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `NSSearchField` that says when it takes the keyboard.
|
||||
/// An `NSSearchField` that says when it takes the keyboard, and keeps its controller alive.
|
||||
///
|
||||
/// The gain is reported here rather than through `controlTextDidBeginEditing` because that
|
||||
/// notification is about an *edit session*, and the caret-chord rule is about focus: a field the
|
||||
@@ -241,13 +412,75 @@ struct BoardSearchField: NSViewRepresentable {
|
||||
/// is the delegate's `controlTextDidEndEditing`, which fires when the field editor goes — the
|
||||
/// symmetric hook (`resignFirstResponder`) is the field editor's rather than the control's and never
|
||||
/// reaches this class.
|
||||
private final class FocusReportingSearchField: NSSearchField {
|
||||
///
|
||||
/// `controller` is the one strong link in the pair: `NSControl.delegate` is weak, and a controller
|
||||
/// nobody held would be gone before the first keystroke.
|
||||
final class FocusReportingSearchField: NSSearchField {
|
||||
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
var controller: BoardSearchFieldController?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { onFocusChange?(true) }
|
||||
return accepted
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The transient host
|
||||
|
||||
/// The search field one row below its home — what ⌘F raises when the toolbar item has been removed
|
||||
/// (03-board-ui.md ▸ Toolbar: "with the field removed from the toolbar, invoking it surfaces the
|
||||
/// field transiently until the search clears").
|
||||
///
|
||||
/// **A strip in the window's content, not an item pushed back into the toolbar.** Re-inserting the
|
||||
/// item would fight the thing the user just did — the arrangement is theirs, and it is autosaved —
|
||||
/// and would leave the Customize sheet describing a toolbar that is about to change under it. A
|
||||
/// find-bar-shaped strip below the title bar is the platform's own answer for a search surface that
|
||||
/// comes and goes, and it leaves the toolbar exactly as customized.
|
||||
///
|
||||
/// It renders the *same* field as the toolbar item, made by the same factory: one implementation of
|
||||
/// Escape's staging, of the live write-through, and of the focus reporting, whichever home it is in.
|
||||
struct BoardSearchBar: View {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
BoardSearchFieldView(store: store, presentation: presentation)
|
||||
.frame(width: 220)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The field, hosted for SwiftUI — the transient host's half of the two homes.
|
||||
private struct BoardSearchFieldView: NSViewRepresentable {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
BoardSearchFieldController.makeField(store: store, presentation: presentation, home: .transient)
|
||||
}
|
||||
|
||||
/// **Where the ⌘F that raised this bar lands.** The invocation happened one update ago, before
|
||||
/// this view existed, so the keyboard is claimed here — after SwiftUI has put the field in a
|
||||
/// window, which `makeNSView` cannot promise.
|
||||
func updateNSView(_ field: NSSearchField, context: Context) {
|
||||
guard presentation.consumeFocusOnAppear() else { return }
|
||||
Task { @MainActor in
|
||||
guard let window = field.window else { return }
|
||||
window.makeFirstResponder(field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user