The board search item becomes the stock NSSearchToolbarItem — the field grows on focus
The toolbar's search field now lives in AppKit's own NSSearchToolbarItem: em(17) is the focused width (preferredWidthForSearchField), the resting width is the item's own, and ⌘F becomes beginSearchInteraction — one call that expands and focuses. Escape's empty-field exit collapses the field as the keyboard leaves. resignsFirstResponderWithCancel is off so the staged Escape survives the cancel button. The item ships its own live overflow row and an above-.high visibility priority, so both custom-view workarounds retire. The palette copy stays inert through an enablement round-trip the item's searchField setter would otherwise overwrite. The centering half of the card is blocked on a Design ruling (03 settles "trailing") and is not built; the two-homes width question is filed. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -202,20 +202,23 @@ struct BoardToolbarTests {
|
||||
|
||||
#expect(search.focusField == nil, "nothing to focus until the item exists")
|
||||
|
||||
// `NSSearchToolbarItem.view` is unavailable — the item owns its layout — so the field is
|
||||
// reached through `searchField`, which is also where it is handed in.
|
||||
let palette = try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
itemForItemIdentifier: .boardSearch,
|
||||
willBeInsertedIntoToolbar: false
|
||||
))
|
||||
#expect(palette.view is NSSearchField)
|
||||
) as? NSSearchToolbarItem)
|
||||
#expect(!palette.searchField.isEnabled, "the palette's copy is a picture, not a second field")
|
||||
#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)
|
||||
) as? NSSearchToolbarItem)
|
||||
let field = installed.searchField
|
||||
#expect(field.isEnabled, "the real one takes typing")
|
||||
#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
|
||||
@@ -225,6 +228,63 @@ struct BoardToolbarTests {
|
||||
#expect(store.searchQuery == "spec")
|
||||
}
|
||||
|
||||
@Test("The search item is AppKit's own, configured for grow-on-focus and a staged Escape")
|
||||
func searchItemIsTheStockOne() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation())
|
||||
|
||||
let item = try #require(controller.toolbar(
|
||||
controller.toolbar,
|
||||
itemForItemIdentifier: .boardSearch,
|
||||
willBeInsertedIntoToolbar: true
|
||||
) as? NSSearchToolbarItem)
|
||||
|
||||
// The em-based figure is the *focused* width: `NSSearchToolbarItem` applies its preferred
|
||||
// width "whenever it gets the keyboard focus", and the resting width is the item's own
|
||||
// (10-accessibility.md ▸ Text scaling: the number is characters, never points).
|
||||
#expect(
|
||||
item.preferredWidthForSearchField
|
||||
== BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)
|
||||
)
|
||||
// **Escape is staged** (04-interactions.md ▸ Search, settled: "in a non-empty field it
|
||||
// clears the query, focus staying in the field"). AppKit's default cancel button clears
|
||||
// *and* resigns, which would collapse that first step into the second.
|
||||
#expect(!item.resignsFirstResponderWithCancel)
|
||||
|
||||
// The overflow row is the item's, and assigning here — `nil` included — destroys it. A
|
||||
// custom-view item had to supply one; this one must not.
|
||||
#expect(item.menuFormRepresentation != nil, "the item ships a live overflow row")
|
||||
// Its own priority already sits above `.high`, so the nudge a custom-view item needed
|
||||
// would be a demotion.
|
||||
#expect(item.visibilityPriority.rawValue > NSToolbarItem.VisibilityPriority.high.rawValue)
|
||||
}
|
||||
|
||||
@Test("⌘F answers false for a field no window can give the keyboard to")
|
||||
func focusHandleAnswersForAnUnrootedField() 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)
|
||||
|
||||
_ = controller.toolbar(
|
||||
controller.toolbar,
|
||||
itemForItemIdentifier: .boardSearch,
|
||||
willBeInsertedIntoToolbar: true
|
||||
)
|
||||
|
||||
// The item exists, so the handle does. Nothing has put it in a window — the same shape the
|
||||
// system overflow leaves the field in — so ⌘F must fall through to the transient strip
|
||||
// rather than claim a focus it did not get (`BoardSearchPresentation.focusField`).
|
||||
let focusField = try #require(search.focusField)
|
||||
#expect(focusField() == false)
|
||||
|
||||
search.invokeSearch()
|
||||
#expect(search.isTransient, "⌘F always summons search")
|
||||
}
|
||||
|
||||
@Test("Show Trash is a toggle whose state is the View menu's checkmark")
|
||||
func showTrashTogglesTheQuasiLane() throws {
|
||||
let fixture = try makeBoard()
|
||||
@@ -514,3 +574,151 @@ struct BoardSearchSurfacingTests {
|
||||
#expect(!presentation.isTransient)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The field's two intercepted keys
|
||||
|
||||
/// An `NSSearchToolbarItem` that counts the two interactions the field drives — the only way to see
|
||||
/// grow-on-focus and its undo without a window, since both are AppKit's own animation underneath.
|
||||
private final class RecordingSearchItem: NSSearchToolbarItem {
|
||||
|
||||
var interactionsBegun = 0
|
||||
var interactionsEnded = 0
|
||||
|
||||
override func beginSearchInteraction() {
|
||||
interactionsBegun += 1
|
||||
super.beginSearchInteraction()
|
||||
}
|
||||
|
||||
override func endSearchInteraction() {
|
||||
interactionsEnded += 1
|
||||
super.endSearchInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
/// **Escape is staged and Return is swallowed** (04-interactions.md ▸ Search, settled) — the two keys
|
||||
/// the field gives meanings of its own, and the only two: "every key with the field focused acts on
|
||||
/// the field — stock `NSSearchField` behavior, no pass-throughs".
|
||||
@MainActor
|
||||
@Suite("Toolbar ▸ the search field's two keys")
|
||||
struct BoardSearchFieldKeyTests {
|
||||
|
||||
/// The field as its toolbar home builds it, adopted by an item that records what it is asked to
|
||||
/// do — `BoardToolbar`'s wiring, with the item swapped for one that can be read.
|
||||
private func makeToolbarField(
|
||||
store: BoardStore,
|
||||
presentation: BoardSearchPresentation
|
||||
) -> (NSSearchField, RecordingSearchItem) {
|
||||
let field = BoardSearchFieldController.makeField(
|
||||
store: store,
|
||||
presentation: presentation,
|
||||
home: .toolbar
|
||||
)
|
||||
let item = RecordingSearchItem(itemIdentifier: .boardSearch)
|
||||
item.searchField = field
|
||||
BoardSearchFieldController.adopt(item, presentation: presentation)
|
||||
return (field, item)
|
||||
}
|
||||
|
||||
private func send(_ selector: Selector, to field: NSSearchField) -> Bool? {
|
||||
field.delegate?.control?(field, textView: NSTextView(), doCommandBy: selector)
|
||||
}
|
||||
|
||||
@Test("A non-empty field takes Escape as a clear, and keeps the keyboard")
|
||||
func escapeClearsBeforeItLeaves() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let presentation = BoardSearchPresentation()
|
||||
var handedBack = 0
|
||||
presentation.focusBoard = { handedBack += 1 }
|
||||
let (field, item) = makeToolbarField(store: store, presentation: presentation)
|
||||
|
||||
field.stringValue = "spec"
|
||||
field.delegate?.controlTextDidChange?(
|
||||
Notification(name: NSControl.textDidChangeNotification, object: field)
|
||||
)
|
||||
#expect(store.searchQuery == "spec")
|
||||
|
||||
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
|
||||
|
||||
// "In a non-empty field it clears the query, focus staying in the field."
|
||||
#expect(store.searchQuery.isEmpty)
|
||||
#expect(field.stringValue.isEmpty)
|
||||
#expect(handedBack == 0, "one press, one layer")
|
||||
#expect(item.interactionsEnded == 0, "the field keeps the keyboard, so it keeps its width")
|
||||
}
|
||||
|
||||
@Test("An empty field takes Escape as an exit — the board gets the keyboard, the field its width")
|
||||
func escapeHandsTheKeyboardBack() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let presentation = BoardSearchPresentation()
|
||||
var handedBack = 0
|
||||
presentation.focusBoard = { handedBack += 1 }
|
||||
let (field, item) = makeToolbarField(store: store, presentation: presentation)
|
||||
|
||||
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
|
||||
|
||||
// "In an empty field it returns focus to the board" — and the grown field settles back with
|
||||
// the keyboard it just gave up.
|
||||
#expect(handedBack == 1)
|
||||
#expect(item.interactionsEnded == 1)
|
||||
}
|
||||
|
||||
@Test("The transient home takes the same Escape with no item to collapse")
|
||||
func escapeInTheStripHasNoWidthToUndo() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let presentation = BoardSearchPresentation()
|
||||
var handedBack = 0
|
||||
presentation.focusBoard = { handedBack += 1 }
|
||||
let field = BoardSearchFieldController.makeField(
|
||||
store: store,
|
||||
presentation: presentation,
|
||||
home: .transient
|
||||
)
|
||||
|
||||
#expect(send(#selector(NSResponder.cancelOperation(_:)), to: field) == true)
|
||||
#expect(handedBack == 1, "one implementation of the staircase, whichever home it is in")
|
||||
}
|
||||
|
||||
@Test("Return is a swallowed no-op, and every other key falls through to the field editor")
|
||||
func returnIsSwallowedAndTheRestPassThrough() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let presentation = BoardSearchPresentation()
|
||||
let (field, _) = makeToolbarField(store: store, presentation: presentation)
|
||||
|
||||
field.stringValue = "spec"
|
||||
field.delegate?.controlTextDidChange?(
|
||||
Notification(name: NSControl.textDidChangeNotification, object: field)
|
||||
)
|
||||
|
||||
// "The filter is live, there is nothing to submit — it never reaches the board's
|
||||
// rename/create grammar."
|
||||
#expect(send(#selector(NSResponder.insertNewline(_:)), to: field) == true)
|
||||
#expect(store.searchQuery == "spec", "a swallowed key changes nothing")
|
||||
|
||||
// "Stock NSSearchField behavior, no pass-throughs": the field editor keeps everything else,
|
||||
// caret motion and text selection included.
|
||||
#expect(send(#selector(NSResponder.moveLeft(_:)), to: field) == false)
|
||||
#expect(send(#selector(NSResponder.deleteBackward(_:)), to: field) == false)
|
||||
#expect(send(#selector(NSResponder.moveUp(_:)), to: field) == false)
|
||||
}
|
||||
|
||||
@Test("⌘F expands and focuses in one call — and claims nothing when the field is in no window")
|
||||
func focusHandleIsTheItemsInteraction() throws {
|
||||
let fixture = try makeBoard()
|
||||
defer { fixture.tearDown() }
|
||||
let store = try BoardStore(rootURL: fixture.root)
|
||||
let presentation = BoardSearchPresentation()
|
||||
let (_, item) = makeToolbarField(store: store, presentation: presentation)
|
||||
|
||||
let focusField = try #require(presentation.focusField)
|
||||
#expect(focusField() == false, "a field in no window cannot take the keyboard")
|
||||
#expect(item.interactionsBegun == 0, "and must not report a focus it never took")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user