Implement live search filtering
The board's live title+body filter per 04-interactions.md § Search: - SearchFilter — a pure value folding the query once (case- and diacritic-insensitive substring, locale-stable); title OR body matches, attachment filenames never searched; only the literal empty string is inactive. - One universe: the filter threads through SelectionGrammar's order lists as a defaulted parameter, so ranges, Select All, arrow navigation, the marquee, drop zones, count badges, and the shown trash all read the same filtered set by construction; lanes are deliberately never filtered out (an emptied lane keeps its slot with a 0 badge). Hidden cards leave the selection through the existing constrain primitive, run on every query change and as the last line of the reload resolve; the delete successor is filtered so ⌫ never selects a hidden neighbour. - The field: an NSSearchField-backed toolbar item (the toolbar's sole default item); Edit ▸ Find ⌘F focuses it through a focused-value presentation; stock field-editor dispatch — Return swallowed, Tab is the keep-filter path to the board, board commands stay enabled except the caret-chord pair, now one shared caretChordsYield expression. - Escape is staged: clear the non-empty query (focus stays), hand an empty field back to the board, clear an active search from board focus — before Escape's clear-selection meaning. - Creating a card clears the search (the placeholder funnel); a rename deliberately gets no carve-out; filter reflow rides the content spring keyed narrowly on the query. 903 unit tests (24 new). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -79,6 +79,34 @@ extension BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The caret-chords rule, as one expression** (04-interactions.md ▸ Grammar, settled):
|
||||
///
|
||||
/// > Board ▸ Move Left/Move Right ⌘←/⌘→ and the width pair ⌥⌘←/⌥⌘→ disable via menu validation
|
||||
/// > whenever *any* text control has keyboard focus — inline title editors, the board search field,
|
||||
/// > board-popover fields (rename, git identity, remote), and card-window fields — because an
|
||||
/// > enabled menu key equivalent fires before the field ever sees the key, and ⌘←/⌘→ are the
|
||||
/// > standard line-start/end caret chords.
|
||||
///
|
||||
/// Four text surfaces, answered four ways, and only two of them are here:
|
||||
///
|
||||
/// - **Inline title editors** are `acceptsBoardMutations`', through the focused-editor rule — a
|
||||
/// broader lockdown that already covers these two items.
|
||||
/// - **The board popover's fields** are covered by disabling while the popover is open at all —
|
||||
/// coarser than per-field focus, but it is a configuration surface (04's carve-out) and no lane
|
||||
/// move belongs under it.
|
||||
/// - **The search field** is per-focus and exact (`BoardSearchPresentation.isFocused`), which it has
|
||||
/// to be: the field's own rule is that board commands *stay enabled* while it holds the keyboard
|
||||
/// (04 § Search), so these two are the narrow exception to it and nothing coarser would do.
|
||||
/// - **Card-window fields** need nothing: those windows never publish a `boardStore`, so both items
|
||||
/// are already scopeless there.
|
||||
///
|
||||
/// Stated once because the two command groups must not drift: a rule with two implementations is a
|
||||
/// rule with two chances to forget a surface.
|
||||
@MainActor
|
||||
func caretChordsYield(boardInfo: BoardInfoPresentation?, search: BoardSearchPresentation?) -> Bool {
|
||||
boardInfo?.isPresented == true || search?.isFocused == true
|
||||
}
|
||||
|
||||
// MARK: - Open Card
|
||||
|
||||
/// Board ▸ Open Card (⌘↩) — 11-command-nexus.md's first Board row, and **the one board command
|
||||
@@ -205,32 +233,30 @@ struct MoveCardCommands: View {
|
||||
///
|
||||
/// **Caret chords yield to any focused text control** (04-interactions.md ▸ Grammar, settled):
|
||||
/// ⌘←/⌘→ are the standard line-start/end chords, and an enabled key equivalent fires before a
|
||||
/// field ever sees the key. The inline title editors are covered by `acceptsBoardMutations`; the
|
||||
/// board popover's fields are covered by disabling while the popover is open at all — coarser than
|
||||
/// per-field focus, but the popover is a configuration surface (04's carve-out) and no lane move
|
||||
/// belongs under it. The search field (m5-search) and the card window's fields (whose windows never
|
||||
/// publish a `boardStore` in the first place) extend the same rule with their own cards.
|
||||
/// field ever sees the key. Which surfaces that covers, and how each is answered, is
|
||||
/// `caretChordsYield(boardInfo:search:)`'s doc comment — shared verbatim with the width pair below.
|
||||
struct MoveLaneCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
Button("Move Left") {
|
||||
move(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: .command)
|
||||
.disabled(caretChordsYield || destination(-1) == nil)
|
||||
.disabled(yieldsCaretChords || destination(-1) == nil)
|
||||
|
||||
Button("Move Right") {
|
||||
move(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: .command)
|
||||
.disabled(caretChordsYield || destination(1) == nil)
|
||||
.disabled(yieldsCaretChords || destination(1) == nil)
|
||||
}
|
||||
|
||||
private var caretChordsYield: Bool {
|
||||
boardInfo?.isPresented == true
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The sole selected live lane and the display slot one step would put it in — `nil` when there
|
||||
@@ -431,23 +457,24 @@ struct LaneWidthCommands: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardInfo) private var boardInfo
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
Button("Increase Lane Width") {
|
||||
step(by: 1)
|
||||
}
|
||||
.keyboardShortcut(.rightArrow, modifiers: [.option, .command])
|
||||
.disabled(caretChordsYield || selectedLanes.isEmpty)
|
||||
.disabled(yieldsCaretChords || selectedLanes.isEmpty)
|
||||
|
||||
Button("Decrease Lane Width") {
|
||||
step(by: -1)
|
||||
}
|
||||
.keyboardShortcut(.leftArrow, modifiers: [.option, .command])
|
||||
.disabled(caretChordsYield || !canDecrease)
|
||||
.disabled(yieldsCaretChords || !canDecrease)
|
||||
}
|
||||
|
||||
private var caretChordsYield: Bool {
|
||||
boardInfo?.isPresented == true
|
||||
private var yieldsCaretChords: Bool {
|
||||
caretChordsYield(boardInfo: boardInfo, search: search)
|
||||
}
|
||||
|
||||
/// The selected live lanes, in snapshot order — the batch, and the items' validation.
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
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.
|
||||
///
|
||||
/// `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
|
||||
/// board window's field (Edit ▸ Find ⌘F) without anyone keeping a which-window-is-key register.
|
||||
///
|
||||
/// ### Why the focus flag is here rather than on the store
|
||||
///
|
||||
/// Two consumers need it and neither is inside the field: Edit ▸ Find (which focuses it) and the
|
||||
/// **caret-chord commands**, which "disable while the field is focused" (04-interactions.md ▸
|
||||
/// Grammar's caret-chords rule, ▸ Search). Both are menu items, and a menu item reaches a window
|
||||
/// through `@FocusedValue`. It deliberately does *not* live on `BoardStore` beside `searchQuery`:
|
||||
/// the query is the board's (every window on it filters alike), while the focus is one window's
|
||||
/// keyboard — the same split `BoardInfoPresentation` makes for the popover flag.
|
||||
///
|
||||
/// **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.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class BoardSearchPresentation {
|
||||
|
||||
/// Whether the search field holds keyboard focus — the caret-chord commands' one input.
|
||||
///
|
||||
/// Written by the field itself, on `becomeFirstResponder` and on the field editor ending. It is
|
||||
/// therefore *the field's* answer rather than an inference from SwiftUI focus state, which is
|
||||
/// 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)?
|
||||
|
||||
/// 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)?
|
||||
}
|
||||
|
||||
/// The focused board window's search field, beside `FocusedValues.boardStore` and
|
||||
/// `FocusedValues.boardInfo` — see `FocusedBoardStoreKey` for why board-window menu items reach
|
||||
/// their window this way.
|
||||
struct FocusedBoardSearchKey: FocusedValueKey {
|
||||
typealias Value = BoardSearchPresentation
|
||||
}
|
||||
|
||||
extension FocusedValues {
|
||||
var boardSearch: BoardSearchPresentation? {
|
||||
get { self[FocusedBoardSearchKey.self] }
|
||||
set { self[FocusedBoardSearchKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit ▸ Find
|
||||
|
||||
/// Edit ▸ Find (⌘F) — "Board window: board search" (11-command-nexus.md; 04-interactions.md
|
||||
/// § Search: "Search field invoked with ⌘F").
|
||||
///
|
||||
/// **Focus, not toggle.** ⌘I toggles the board popover because a shortcut that could only ever open
|
||||
/// would leave that surface with no keyboard way out; the search field's way out is Escape's staged
|
||||
/// exit, which the field owns, so ⌘F only ever means "put the keyboard here" — and pressing it with
|
||||
/// the field already focused is a no-op the user cannot tell from a re-focus.
|
||||
///
|
||||
/// **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.
|
||||
///
|
||||
// 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.
|
||||
struct FindCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
@FocusedValue(\.boardSearch) private var search
|
||||
|
||||
var body: some View {
|
||||
Button("Find") {
|
||||
search?.focusField?()
|
||||
}
|
||||
.keyboardShortcut("f", modifiers: .command)
|
||||
.disabled(store == nil || search?.focusField == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The field
|
||||
|
||||
/// The board toolbar's search field — an `NSSearchField`, hosted.
|
||||
///
|
||||
/// ### Why AppKit and not `.searchable`
|
||||
///
|
||||
/// Two requirements SwiftUI's modifier does not meet, both normative:
|
||||
///
|
||||
/// - **Explicit focus control.** ⌘F must put the keyboard in this field from a *menu item*, and
|
||||
/// Escape in an empty field must hand the keyboard back to the strip. `.searchable` owns its own
|
||||
/// focus and offers no handle on either half; a first responder is what both need, so the field
|
||||
/// has to be a view something can hold.
|
||||
/// - **Stock `NSSearchField` key behaviour.** 04-interactions.md § Search settles the dispatch as
|
||||
/// "every key with the field focused acts on the field — stock `NSSearchField` behavior, no
|
||||
/// pass-throughs", which is a promise about *AppKit's* text-field key handling: the field editor
|
||||
/// takes the arrows as caret motion, ⇧-arrows as text selection, ⌫ as backspace, ⌘A/⌘X/⌘C/⌘V as
|
||||
/// the text clipboard, and ⌘Z as the field's own text undo. Hosting the real control is how that
|
||||
/// sentence is implemented rather than reimplemented.
|
||||
///
|
||||
/// Only the two keys the design gives *different* meanings are intercepted (`doCommandBy` below):
|
||||
/// Return, which is a swallowed no-op because a live filter has nothing to submit, and Escape,
|
||||
/// whose staging is the design's own and not the cancel button's.
|
||||
///
|
||||
/// ### Live per keystroke
|
||||
///
|
||||
/// `controlTextDidChange` writes straight through to `BoardStore.searchQuery`, which is the filter
|
||||
/// (`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.
|
||||
///
|
||||
/// ### 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
|
||||
/// moves focus to the board with the query intact, and the whole board grammar then applies over the
|
||||
/// *filtered* board; ⌘F returns to the field" (04 § Search). Tab is the key-view loop's, untouched;
|
||||
/// the query survives because only two things ever clear it — Escape and creation — and neither is
|
||||
/// a blur.
|
||||
///
|
||||
/// **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 {
|
||||
|
||||
let store: BoardStore
|
||||
let presentation: BoardSearchPresentation
|
||||
|
||||
func makeNSView(context: Context) -> NSSearchField {
|
||||
let field = FocusReportingSearchField()
|
||||
// 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.placeholderString = "Search"
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(owner: self)
|
||||
}
|
||||
|
||||
/// The field's delegate: the live write-through, and the two keys 04 gives meanings of its own.
|
||||
final class Coordinator: NSObject, NSSearchFieldDelegate {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `NSSearchField` that says when it takes the keyboard.
|
||||
///
|
||||
/// 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
|
||||
/// user has Tabbed or ⌘F'd into but not yet typed in already owns ⌘←/⌘→ as line-start/end. The loss
|
||||
/// 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 {
|
||||
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let accepted = super.becomeFirstResponder()
|
||||
if accepted { onFocusChange?(true) }
|
||||
return accepted
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,19 @@ import SwiftUI
|
||||
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
|
||||
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
||||
///
|
||||
/// ### The live search filter
|
||||
///
|
||||
/// The filter itself is a pure predicate (`SearchFilter`) and the field is the toolbar's
|
||||
/// (`BoardSearchField`, installed by `BoardWindowHost`); what belongs to this file is the two places
|
||||
/// the board *reads* it — the arrow grammar's order lists and jump containers, so navigation walks
|
||||
/// the filtered board, and Escape's middle step. Everything else follows from `LaneView`'s and
|
||||
/// `TrashLaneView`'s own narrowing, because the drop zones, the marquee and the file-drop targets
|
||||
/// all read what those two rendered.
|
||||
///
|
||||
/// ### What is deliberately not here yet
|
||||
///
|
||||
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which
|
||||
/// join the very drop delegates this file already attaches (see `BoardDrops.swift`).
|
||||
/// External Finder file drops join the very drop delegates this file already attaches (see
|
||||
/// `BoardDrops.swift`).
|
||||
struct BoardView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -60,6 +69,11 @@ struct BoardView: View {
|
||||
/// needs the board's own window ref, which is the host's identity and not the board's.
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The toolbar search field's handle (`BoardSearchPresentation`), threaded down so the strip can
|
||||
/// fill in `focusBoard` — Escape's "in an empty field it returns focus to the board" needs the
|
||||
/// strip's own `@FocusState`, which nothing outside this view can reach.
|
||||
let search: BoardSearchPresentation
|
||||
|
||||
/// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
|
||||
/// Styling ▸ Controls).
|
||||
@Environment(AppModel.self) private var appModel
|
||||
@@ -161,7 +175,15 @@ struct BoardView: View {
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focused($isBoardFocused)
|
||||
.onAppear { isBoardFocused = true }
|
||||
.onAppear {
|
||||
isBoardFocused = true
|
||||
// **Escape's second step, wired from the side that can perform it** (04 § Search): the
|
||||
// field can resign first responder on its own, but only the strip can *take* the
|
||||
// keyboard, and a window with a resigned field and an unfocused board would swallow
|
||||
// every grammar key. `@FocusState`'s setter is nonmutating, so the closure writes the
|
||||
// same storage this view reads.
|
||||
search.focusBoard = { isBoardFocused = true }
|
||||
}
|
||||
.onChange(of: store.isEditingInline) { _, editing in
|
||||
// An editor took focus and has now given it back. Without this the strip stays unfocused
|
||||
// after every rename and Return silently stops working.
|
||||
@@ -274,6 +296,19 @@ struct BoardView: View {
|
||||
// It stays on the `HStack` rather than moving out to the `ZStack`, so the marquee band
|
||||
// drawn beside it is never inside an animated transaction (03 § Motion again).
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal)
|
||||
// **The search filter's reflow**, keyed on **the query** and nothing else — 03-board-ui.md
|
||||
// § Motion names it in the narrow-keys list ("on the search query (filter reflow)") — and in
|
||||
// the *content* voice rather than the structural one: "search filtering and undo/redo
|
||||
// restore, deliberately paired so a restore reads like the search filter — leavers and
|
||||
// arrivers run their transition, survivors reflow under one gentle spring". The leavers and
|
||||
// arrivers are the card and row transitions already attached inside the lanes and the trash
|
||||
// column; this is the survivors' spring around them.
|
||||
//
|
||||
// **Every way the query changes rides it**, which is the reason the key is the query rather
|
||||
// than the transaction being wrapped at each mutation: typing, the field's Escape, the
|
||||
// board's Escape, and creation's clear (`TransientBoardState.beginPlaceholder`) all land
|
||||
// here without any of them knowing about motion.
|
||||
.animation(Motion.contentReflow(reduced: reduceMotion), value: store.searchQuery)
|
||||
}
|
||||
|
||||
/// The rubber band itself: a translucent accent fill with a hairline border, in strip
|
||||
@@ -409,6 +444,18 @@ struct BoardView: View {
|
||||
store.transient.isTrashVisible
|
||||
}
|
||||
|
||||
/// The trash's rows as the column is showing them — the shown trash "participates in the filter
|
||||
/// like any lane" (03-board-ui.md § Trash), and the arrows walk what is on screen
|
||||
/// (`TrashLaneView.entries` applies the identical predicate to the identical rows).
|
||||
///
|
||||
/// Read by the three keyboard destinations that reach into the column — the arrow origin's
|
||||
/// order list, ⌥↑/⌥↓'s container, and ⌥→'s jump — so none of them can walk onto a row the
|
||||
/// filter took away.
|
||||
private var trashEntries: [TrashEntry] {
|
||||
let filter = store.searchFilter
|
||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - The drag
|
||||
|
||||
/// What each of this window's drop targets — and its lanes' autoscroll drivers — is handed.
|
||||
@@ -565,12 +612,20 @@ struct BoardView: View {
|
||||
}
|
||||
|
||||
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
||||
/// clear the selection.
|
||||
/// clear the search, else clear the selection.
|
||||
///
|
||||
/// The middle step — clearing an active search and returning focus to the board — is m5's, and
|
||||
/// it slots between these two once the search field exists.
|
||||
/// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
|
||||
/// *board* focus and an active search, one press clears the search and the full board returns —
|
||||
/// search takes Escape before its clear-selection meaning, which applies only when no search is
|
||||
/// active." So a board-focused Escape under a query returns the board and *keeps* the selection;
|
||||
/// a second press then deselects. One press, one layer, all the way out.
|
||||
///
|
||||
/// The editors handle Escape themselves while they hold focus; this branch is the outer net for
|
||||
/// This is the third step of a staircase whose first two are the field's own — a non-empty field
|
||||
/// clears its query and keeps the keyboard, an empty one hands the keyboard back here — and the
|
||||
/// two halves never both fire, because exactly one of the field and the strip holds focus (see
|
||||
/// `BoardSearchField`).
|
||||
///
|
||||
/// The editors handle Escape themselves while they hold focus; that branch is the outer net for
|
||||
/// the case where focus has drifted off the field with an editor still open, and it abandons
|
||||
/// both kinds because at most one can be open at a time.
|
||||
private func handleEscape() -> KeyPress.Result {
|
||||
@@ -579,6 +634,10 @@ struct BoardView: View {
|
||||
store.transient.discardRename()
|
||||
return .handled
|
||||
}
|
||||
if !store.searchQuery.isEmpty {
|
||||
store.clearSearch()
|
||||
return .handled
|
||||
}
|
||||
guard !store.selection.isEmpty else { return .ignored }
|
||||
store.clearSelection()
|
||||
return .handled
|
||||
@@ -657,6 +716,9 @@ struct BoardView: View {
|
||||
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk
|
||||
/// every trash entry in its sorted order — card and lane entries alike" (04 ▸ The trash). The
|
||||
/// per-kind lists are the *range*'s business, not the walk's.
|
||||
///
|
||||
/// Both lists are the **filtered** board (04 § Search: "arrow nav … read[s] it"), so the
|
||||
/// fallback lands on the last *visible* member rather than on a card the query hid.
|
||||
private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return nil }
|
||||
@@ -667,10 +729,10 @@ struct BoardView: View {
|
||||
case .live:
|
||||
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
|
||||
isLaneDomain = kind == .lane
|
||||
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot)
|
||||
list = SelectionGrammar.order(of: kind, on: .live, in: store.snapshot, filter: store.searchFilter)
|
||||
case .trashed:
|
||||
isLaneDomain = false
|
||||
list = TrashModel.entries(of: store.snapshot).map(\.id)
|
||||
list = trashEntries.map(\.id)
|
||||
}
|
||||
|
||||
if let head = store.transient.selectionHead, list.contains(head) {
|
||||
@@ -692,7 +754,7 @@ struct BoardView: View {
|
||||
if mode == .jump, direction == .left || direction == .right {
|
||||
return jumpToEndLane(direction)
|
||||
}
|
||||
guard let first = Self.firstCard(scanning: liveLanes) else { return .handled }
|
||||
guard let first = Self.firstCard(scanning: liveLanes, filter: store.searchFilter) else { return .handled }
|
||||
replaceSelection(with: first, on: .live)
|
||||
return .handled
|
||||
}
|
||||
@@ -763,7 +825,10 @@ struct BoardView: View {
|
||||
to: next.id,
|
||||
kind: next.kind,
|
||||
on: next.side,
|
||||
in: store.snapshot
|
||||
in: store.snapshot,
|
||||
// The span is the *filtered* board's, so a range under a search collects exactly the
|
||||
// rows between the two endpoints that are on screen (04 § Search: "ranges … read it").
|
||||
filter: store.searchFilter
|
||||
) else { return .handled }
|
||||
store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
|
||||
return .handled
|
||||
@@ -785,13 +850,16 @@ struct BoardView: View {
|
||||
var lane: ItemID?
|
||||
switch side {
|
||||
case .trashed:
|
||||
container = TrashModel.entries(of: store.snapshot).map(\.id)
|
||||
container = trashEntries.map(\.id)
|
||||
case .live:
|
||||
guard let home = store.snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
|
||||
}) else { return .handled }
|
||||
lane = home.id
|
||||
container = home.cards.filter { !$0.isDeleted }.map(\.id)
|
||||
// The container is what the lane is *showing*: a jump to "the lane's first card" under
|
||||
// a search means its first surviving card, not one the filter animated out.
|
||||
let filter = store.searchFilter
|
||||
container = home.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
|
||||
}
|
||||
|
||||
guard let target = direction == .up ? container.first : container.last else { return .handled }
|
||||
@@ -811,14 +879,17 @@ struct BoardView: View {
|
||||
/// the jump falls through to the last lane. Empty lanes are scanned past in both directions —
|
||||
/// a jump that landed nowhere because the end lane happens to be empty would be a dead key.
|
||||
private func jumpToEndLane(_ direction: NavigationMath.Direction) -> KeyPress.Result {
|
||||
if direction == .right, isTrashVisible, let first = TrashModel.entries(of: store.snapshot).first {
|
||||
if direction == .right, isTrashVisible, let first = trashEntries.first {
|
||||
replaceSelection(with: first.id, on: .trashed)
|
||||
return .handled
|
||||
}
|
||||
let lanes = liveLanes
|
||||
let filter = store.searchFilter
|
||||
// A lane the search emptied is scanned past exactly as an empty one is — the jump lands on
|
||||
// the first lane that is *showing* a card, which is what the user can see.
|
||||
let target = direction == .right
|
||||
? Self.firstCard(scanning: lanes.reversed())
|
||||
: Self.firstCard(scanning: lanes)
|
||||
? Self.firstCard(scanning: lanes.reversed(), filter: filter)
|
||||
: Self.firstCard(scanning: lanes, filter: filter)
|
||||
guard let target else { return .handled }
|
||||
replaceSelection(with: target, on: .live)
|
||||
return .handled
|
||||
@@ -890,9 +961,15 @@ struct BoardView: View {
|
||||
|
||||
/// The first rendered card of the first lane that has one — the scan every "first/last lane"
|
||||
/// destination shares, run over the lane order forwards or reversed.
|
||||
private static func firstCard(scanning lanes: some Sequence<Lane>) -> ItemID? {
|
||||
///
|
||||
/// "Rendered" includes the search filter, so a lane whose cards the query all hid is scanned
|
||||
/// past like an empty one — `liveCards(in:filter:)`'s membership, one lane at a time.
|
||||
private static func firstCard(
|
||||
scanning lanes: some Sequence<Lane>,
|
||||
filter: SearchFilter = .inactive
|
||||
) -> ItemID? {
|
||||
for lane in lanes {
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted }) { return card.id }
|
||||
if let card = lane.cards.first(where: { !$0.isDeleted && filter.matches($0) }) { return card.id }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -583,11 +583,17 @@ struct LaneView: View {
|
||||
/// layout that re-admitted them on every flip would flap the board under the cursor
|
||||
/// (DRAG-REORDER.md § Resting-layout zones). The copy's originals reappear when the write lands.
|
||||
///
|
||||
/// This is also the collection m5's search filter narrows, which is what keeps the count badge
|
||||
/// honest for free — see `countBadge`.
|
||||
/// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
|
||||
/// "cards whose title *and* body both miss the query animate out". This is the one collection
|
||||
/// that narrowing, which is what makes the filter "the single source of truth for what's on the
|
||||
/// board" true of this lane's every surface at once — the masonry, the count badge (see
|
||||
/// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder
|
||||
/// file-drop targets all read this list or the registry it populates, so none of them needs a
|
||||
/// rule of its own.
|
||||
private var renderedCards: [Card] {
|
||||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) }
|
||||
let filter = store.searchFilter
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
@@ -91,6 +91,19 @@ extension View {
|
||||
/// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives
|
||||
/// in — the band's own points come from a drag gesture in the same space, so no conversion
|
||||
/// happens anywhere.
|
||||
///
|
||||
/// **This is also how the search filter reaches the band and the arrows** (04-interactions.md
|
||||
/// § Search, "marquee, … arrow nav … all read it"): a card the filter hides is never built, so
|
||||
/// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the
|
||||
/// masonry rather than re-running the predicate.
|
||||
///
|
||||
/// One bounded honesty about that: a card leaving under the filter's transition stays registered
|
||||
/// until the transition ends (`onDisappear` fires when the view really goes, not when the query
|
||||
/// changed), so for the length of one content-reflow spring a fading card is still sweepable and
|
||||
/// still an arrow's neighbour. It is on screen for exactly that span, and it has already left the
|
||||
/// selection (`TransientBoardState.constrainToSearch(in:)` runs at the keystroke), so the window
|
||||
/// is visible rather than phantom — accepted rather than closed by teaching three input sites a
|
||||
/// predicate the layout already applied.
|
||||
@MainActor
|
||||
func marqueeTarget(
|
||||
_ id: ItemID,
|
||||
|
||||
@@ -34,8 +34,9 @@ import SwiftUI
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The **search filter** ("shown, it participates in the filter like any lane") and **⌘C copy-out**
|
||||
/// are still owed. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
|
||||
/// **⌘C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
|
||||
/// any lane") arrived with m5 and is one line — see `entries`, which every other surface here reads
|
||||
/// through. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the
|
||||
/// board does, and the column's empty space rubber-bands on the trashed side. So is the drop, with
|
||||
/// the target lane highlighted and the source row dimmed in place; the drag's replica is not. The
|
||||
/// **keyboard** reaches the column entirely through the frames the rows register — arrow walks in
|
||||
@@ -83,11 +84,14 @@ struct TrashLaneView: View {
|
||||
|
||||
/// The rows the column shows.
|
||||
///
|
||||
// m5-search: the shown trash "participates in the filter like any lane", so the search predicate
|
||||
// narrows this collection exactly as it narrows `LaneView.renderedCards` — and the count badge
|
||||
// follows for free, because it reads this same value.
|
||||
/// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
|
||||
/// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
|
||||
/// — card rows and lane rows alike, each by its own title and body (`SearchFilter`) — and the
|
||||
/// count badge follows for free, because it reads this same value. Hidden, the column renders
|
||||
/// nothing and registers nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
private var entries: [TrashEntry] {
|
||||
TrashModel.entries(of: store.snapshot)
|
||||
let filter = store.searchFilter
|
||||
return TrashModel.entries(of: store.snapshot).filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
+20
-10
@@ -109,8 +109,14 @@ enum Motion {
|
||||
/// restore reads like the search filter — leavers and arrivers run their transition, survivors
|
||||
/// reflow under one gentle spring".
|
||||
///
|
||||
/// Named ahead of both its call sites (m5's search, m7's undo) for the same reason `delete` is:
|
||||
/// the figure is settled, and a duration that has nowhere to live gets spelled at a call site.
|
||||
/// **The search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction
|
||||
/// keyed on the query and nothing else — 03's own narrow key for this reflow. The leavers and
|
||||
/// arrivers it talks about are `cardTransition`, already attached to every card slot and trash
|
||||
/// row, so the two halves of the sentence are two modifiers rather than one bespoke animation.
|
||||
///
|
||||
// m7-undo: the restore is the other half of the pair. It arrives as a bracketed wholesale
|
||||
// reload, so it reaches this voice through `reloadAnimation` rather than through a transaction
|
||||
// of its own — see `reloadAnimates(origin:endsBracketedOperation:)`.
|
||||
static func contentReflow(reduced: Bool) -> Animation? {
|
||||
reduced ? nil : .smooth(duration: Duration.contentReflow)
|
||||
}
|
||||
@@ -145,8 +151,9 @@ enum Motion {
|
||||
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
|
||||
}
|
||||
|
||||
/// A card arriving or leaving — a create, a delete, a Put Back, and (m5) a search filter's
|
||||
/// leavers and arrivers. The trash's rows wear it too: they are cards, and 10 requires the trash
|
||||
/// A card arriving or leaving — a create, a delete, a Put Back, and the search filter's leavers
|
||||
/// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a
|
||||
/// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
|
||||
/// animations to have a reduced variant like everything else.
|
||||
static func cardTransition(reduced: Bool) -> AnyTransition {
|
||||
cardAppearance(reduced: reduced).transition
|
||||
@@ -243,12 +250,15 @@ enum Motion {
|
||||
/// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
|
||||
/// applied in, or `nil` for the reloads that snap.
|
||||
///
|
||||
// m5-drag, m5-search: the voice is the *general* structural spring for every app-mediated
|
||||
// reload, because the reload seam knows an operation echoed but not which one — 03 gives delete
|
||||
// 0.25 s and a drop commit its own dialect, and neither is reachable from an origin tag. The
|
||||
// per-operation figures (`delete`, `dragReflow`) become reachable when the operations that own
|
||||
// them run their own transactions around the gesture, which is m5's card; this seam stays the
|
||||
// floor under them.
|
||||
// m5-drag: the voice is the *general* structural spring for every app-mediated reload, because
|
||||
// the reload seam knows an operation echoed but not which one — 03 gives delete 0.25 s and a
|
||||
// drop commit its own dialect, and neither is reachable from an origin tag. The per-operation
|
||||
// figures (`delete`, `dragReflow`) become reachable when the operations that own them run their
|
||||
// own transactions around the gesture; this seam stays the floor under them.
|
||||
//
|
||||
// The search filter needed none of that and never reaches here: a query change is not a reload
|
||||
// at all — it is transient state, so its transaction is wrapped where it happens
|
||||
// (`BoardView.laneStrip`, `contentReflow`).
|
||||
static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
|
||||
guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
|
||||
return structural(reduced: reduced)
|
||||
|
||||
Reference in New Issue
Block a user