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:
2026-07-27 22:49:15 -04:00
parent 7eee0934ee
commit cf87b72092
15 changed files with 1359 additions and 84 deletions
+28 -3
View File
@@ -50,6 +50,12 @@ struct BoardWindowHost: View {
/// the board half of a card window's `(board, card)` identity see `CardOpener`. /// the board half of a card window's `(board, card)` identity see `CardOpener`.
@State private var cardOpener = CardOpener() @State private var cardOpener = CardOpener()
/// This window's toolbar search field, as a handle (`BoardSearchPresentation`). `@State` for
/// `boardInfo`'s reason one per window and published the same way, because Edit Find F
/// and the caret-chord commands are menu-bar items that have to reach the frontmost board
/// window's field.
@State private var boardSearch = BoardSearchPresentation()
@State private var phase: Phase = .opening @State private var phase: Phase = .opening
private enum Phase { private enum Phase {
@@ -89,13 +95,32 @@ struct BoardWindowHost: View {
store: store, store: store,
window: { windowController.window }, window: { windowController.window },
confirmations: trashConfirmations, confirmations: trashConfirmations,
openCard: openCard openCard: openCard,
search: boardSearch
) )
} }
// **The board window's toolbar: the search field, nothing else** (03-board-ui.md
// Toolbar, "trailing, the one default item; the titlebar stays clean"). It is a toolbar
// rather than a strip inside the content because that is where 03 puts it, and it hosts
// an `NSSearchField` rather than `.searchable` for the reasons `BoardSearchField`
// records explicit first-responder control, and stock key behaviour.
//
// m6-toolbar: the rest of 03's toolbar story is the customization card's the
// Customize palette, the New Card / New Lane / Undo / Redo / Show Trash catalog, and
// with it F's transient surfacing of a *removed* field. That work replaces this
// declaration with an identified, customizable toolbar; the item itself does not move.
.toolbar {
ToolbarItem(placement: .primaryAction) {
BoardSearchField(store: store, presentation: boardSearch)
.frame(width: 220)
}
}
// "The 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, and // beside it the window's own popover flag, which is what File Board Info toggles, its
// its purge-alert host, which the trash's two confirmed commands raise. // purge-alert host, which the trash's two confirmed commands raise, and its search
// field, which Edit Find focuses and the caret-chord commands yield to.
.focusedSceneValue(\.boardStore, store) .focusedSceneValue(\.boardStore, store)
.focusedSceneValue(\.boardSearch, boardSearch)
// The window's identity beside its store File Duplicate flushes a *session*, which // The window's identity beside its store File Duplicate flushes a *session*, which
// is keyed on the window rather than on the board it is showing. // is keyed on the window rather than on the board it is showing.
.focusedSceneValue(\.boardWindowRef, ref) .focusedSceneValue(\.boardWindowRef, ref)
+13
View File
@@ -159,6 +159,19 @@ struct KanbanApp: App {
TrashCommands() TrashCommands()
} }
// The Edit menu's one row of ours: Find (F), placed after the standard Cut/Copy/Paste/
// Select All group, which is where macOS puts Find. Undo/Redo and the clipboard items are
// the system's and the board answers them as a responder (`ClipboardCommands.swift`) a
// second item sharing one of those titles is what titles-are-API forbids.
//
// m6-card-window: Find Next / Find Previous (G/G) join here, scoped to the card window's
// find bar and "disabled in the board window board search is a live filter, not a cursor"
// (11-command-nexus.md). They wait for the window that owns them rather than shipping as two
// permanently disabled rows.
CommandGroup(after: .pasteboard) {
FindCommand()
}
// 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, alongside the card // items live in which is where 11-command-nexus.md files Show Trash, alongside the card
// window's Edit Body / Raw Source / History still to come. // window's Edit Body / Raw Source / History still to come.
+56 -7
View File
@@ -1907,7 +1907,9 @@ public final class BoardStore {
public func delete(_ ids: Set<ItemID>) { public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) } let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return } guard !folders.isEmpty else { return }
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot) // The successor is drawn from what the lane is *showing*, so a delete under an active search
// walks the filtered lane rather than selecting a card the query has hidden.
let successor = SelectionGrammar.successor(afterDeleting: ids, in: snapshot, filter: searchFilter)
try? performWrite { () throws(BoardWriteError) -> Void in try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders { for folder in folders {
@@ -2122,8 +2124,52 @@ public final class BoardStore {
/// Whether an inline title editor is open `TransientBoardState.isEditingInline`, which owns /// Whether an inline title editor is open `TransientBoardState.isEditingInline`, which owns
/// what it means and why every mutating command reads it. /// what it means and why every mutating command reads it.
///
/// **The search field is not one of these**, and that is 04-interactions.md § Search's settled
/// dispatch rule as one absence: "the field is a *control*, not a content editor the
/// focused-editor lockdown does not apply", so board menu commands stay enabled and act on the
/// selection while the user types a query. The narrow exception the caret chords is the
/// menu items' own (`caretChordsYield`), not this flag's.
public var isEditingInline: Bool { transient.isEditingInline } public var isEditingInline: Bool { transient.isEditingInline }
// MARK: - The live search filter
/// The search field's text (04-interactions.md § Search), and **the one funnel every change to
/// it goes through**.
///
/// The setter is where the filter's one consequence lives: narrowing the query narrows what the
/// board shows, and "hidden cards leave the selection" so every write re-applies
/// `TransientBoardState.constrainToSearch(in:)` against the current snapshot. Putting it here
/// rather than at the field's binding is what makes it true for Escape's clear and for any later
/// caller equally, without either having to remember.
///
/// **The equality guard is not an optimisation.** `NSSearchField` reports its text on events
/// that did not change it, and a re-entrant assignment during a live keystroke would re-run the
/// constraint (harmlessly) and re-fire observation (not harmlessly the strip's animated
/// transaction is keyed on this value).
public var searchQuery: String {
get { transient.searchQuery }
set {
guard newValue != transient.searchQuery else { return }
transient.searchQuery = newValue
transient.constrainToSearch(in: snapshot)
}
}
/// The query as the predicate, for the selection grammar's order lists read wherever the board
/// asks "what is on the board, in what order" (`SelectionGrammar.order`).
public var searchFilter: SearchFilter { SearchFilter(query: transient.searchQuery) }
/// Clears the search **Escape's middle step** (04 § Search's staged Escape: "with *board*
/// focus and an active search, one press clears the search and the full board returns"), and the
/// search field's own Escape in a non-empty field.
///
/// Widening, so it constrains nothing; it goes through the setter anyway so there is exactly one
/// place the query is written on the store.
public func clearSearch() {
searchQuery = ""
}
/// Replaces the selection, and **records the lane it lands in** as the last-active one. /// Replaces the selection, and **records the lane it lands in** as the last-active one.
/// ///
/// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it /// The lane bookkeeping lives here rather than in `TransientBoardState` for one reason: it
@@ -2153,7 +2199,9 @@ public final class BoardStore {
selection: selection, selection: selection,
anchor: transient.selectionAnchor, anchor: transient.selectionAnchor,
snapshot: snapshot, snapshot: snapshot,
togglesOnRepeat: togglesOnRepeat togglesOnRepeat: togglesOnRepeat,
// A -range walks the *filtered* board (04 § Search); the other two branches ignore it.
filter: searchFilter
) )
guard !outcome.selection.isEmpty else { guard !outcome.selection.isEmpty else {
clearSelection() clearSelection()
@@ -2186,16 +2234,17 @@ public final class BoardStore {
/// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor, /// dropped otherwise: Select All is not a click, so it names no new origin and no new cursor,
/// but it has no business discarding ones that are still standing inside what it selected. /// but it has no business discarding ones that are still standing inside what it selected.
/// ///
// m5-search: "filter-respecting, like every surface" (04 The map). The universe here is /// **"All visible cards" means the filter's survivors** "filter-respecting, like every
// `SelectionGrammar`'s order lists, which is where the filter threads in one change, and both /// surface" (04 The map). The universe is `SelectionGrammar`'s order lists, which is where the
// this command and every -range narrow together. /// filter threads in, so this command and every -range narrow together by construction.
public func selectAll() { public func selectAll() {
let filter = searchFilter
if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty, if transient.isTrashVisible, selection.liveness == .trashed, !selection.isEmpty,
let kind = SelectionGrammar.kind(of: selection, in: snapshot) { let kind = SelectionGrammar.kind(of: selection, in: snapshot) {
apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot)), on: .trashed) apply(Set(SelectionGrammar.trashEntries(of: kind, in: snapshot, filter: filter)), on: .trashed)
return return
} }
apply(Set(SelectionGrammar.liveCards(in: snapshot)), on: .live) apply(Set(SelectionGrammar.liveCards(in: snapshot, filter: filter)), on: .live)
} }
/// Select All's storage half: an empty universe clears rather than storing an empty set, and the /// Select All's storage half: an empty universe clears rather than storing an empty set, and the
+145
View File
@@ -0,0 +1,145 @@
import Foundation
// MARK: - SearchFilter
/// The live search filter, as a pure predicate over a query and an item (`SearchFilterTests`)
/// 04-interactions.md § Search's one sentence of behaviour and nothing else:
///
/// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic-
/// > insensitive substring. Scope is **title + body only** (settled) attachment filenames are not
/// > searched.
///
/// ### Why it is a value rather than a function
///
/// The needle is folded **once** per filter and matched against each item's folded haystack, so a
/// board-wide pass folds the query once rather than once per card. That is the only reason this is a
/// type: everything else about it is a free function's job, and the value stays `Equatable` so a
/// view can key an animated transaction on it as readily as on the raw query.
///
/// ### One predicate, every surface
///
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
/// ranges, arrow nav, and lane count badges all read it." They read it *here* the masonry through
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
/// trash through `TrashLaneView.entries`, and the selection through
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
///
/// ### What is *not* the predicate's business
///
/// **Lanes are never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss the
/// query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content. `matches(_: Lane)` exists only for the trash, whose rows are
/// tombstoned lanes as often as they are cards and which filter "like any lane" by their own
/// title + body (03-board-ui.md § Trash).
public struct SearchFilter: Sendable, Equatable {
/// The query exactly as typed kept so a caller can key a transaction or a test on it.
public let query: String
/// The query folded once. **Empty means the filter is off**, which is the whole of "empty query
/// = everything visible": every `matches` below short-circuits to `true`.
private let needle: String
public init(query: String) {
self.query = query
needle = Self.folded(query)
}
/// No search the default every threaded parameter carries, so a call site with no query to
/// supply keeps reading exactly as it did before the filter existed.
public static let inactive = SearchFilter(query: "")
/// Whether a search is running at all.
///
/// **Only the empty string is inactive**: whitespace is a legitimate substring (a user typing
/// `fix ` mid-word means it), and trimming would be a rule the design does not state, applied to
/// a live filter where the user sees the result of every keystroke immediately.
public var isActive: Bool { !needle.isEmpty }
// MARK: - The predicate
/// **Title OR body**, which is the positive reading of 04's "cards whose title *and* body both
/// miss the query animate out": a card is hidden only when neither field contains the query, so
/// it is shown when either does.
///
/// An absent title is not a miss to be excused "Untitled" is a rendering, never a value
/// (03-board-ui.md § Card face), so a query only ever matches text the file actually holds.
public func matches(title: String?, body: String) -> Bool {
guard isActive else { return true }
if let title, Self.folded(title).contains(needle) { return true }
return Self.folded(body).contains(needle)
}
/// A card. **Its attachment filenames are not consulted** scope is title + body only
/// (04 § Search, settled), and that is enforced here by construction rather than by remembering
/// not to add `card.attachments` to the line above.
public func matches(_ card: Card) -> Bool {
matches(title: card.title.value, body: card.body)
}
/// A lane, by its own title and description the trash's lane entries, and nothing on the board
/// itself (see the type's doc comment).
public func matches(_ lane: Lane) -> Bool {
matches(title: lane.title.value, body: lane.body)
}
/// A trash row, **by its own title and body**, whichever kind it is: "shown, it participates in
/// the filter like any lane" (03-board-ui.md § Trash), and a lane entry is a row like a card row.
///
/// A lane entry is deliberately *not* matched through its cards: the entry is one restorable
/// thing, and a lane surfacing because a card buried inside it matched would be a row the user
/// cannot act on the way the match suggests.
public func matches(_ entry: TrashEntry) -> Bool {
switch entry {
case let .card(card, _): matches(card)
case let .lane(lane, _): matches(lane)
}
}
// MARK: - The visible universe
/// Every id the filter leaves visible on `side` **the universe
/// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
/// (`TransientBoardState.constrainToSearch(in:)`).
///
/// It is shaped exactly like `ItemReferenceSet.idUniverse(of:on:)` and means the same thing one
/// step narrower: that one answers "what does the board *have*", this one "what does the board
/// *show*". Two differences, both stated above and neither incidental:
///
/// - **Live lanes are all in it.** The filter hides cards, so a lane is visible whatever its
/// cards do a lane selection survives a query that empties its body.
/// - **The trashed side is the trash's rows**, filtered `TrashModel.entries`' absolute
/// ancestor walk, which already excludes the cards a tombstoned lane subsumes. Those have no
/// row, so they are visible to nobody and belong in no universe a selection is held to.
public func visibleIDs(in snapshot: BoardModel, on side: Liveness) -> Set<ItemID> {
switch side {
case .live:
var ids: Set<ItemID> = []
for lane in snapshot.lanes where !lane.isDeleted {
ids.insert(lane.id)
for card in lane.cards where !card.isDeleted && matches(card) {
ids.insert(card.id)
}
}
return ids
case .trashed:
return Set(TrashModel.entries(of: snapshot).lazy.filter { matches($0) }.map(\.id))
}
}
// MARK: - Folding
/// Case- and diacritic-insensitive, and **locale-stable**: `locale: nil` selects the canonical,
/// locale-independent mapping rather than the user's, so a board does not filter differently in
/// a Turkish locale than in an English one. The board is a file on disk, shared across machines
/// and agents; a predicate whose answer depended on System Settings would be a different filter
/// for every user of the same board.
///
/// Both sides are folded with the same call, which is what makes a plain `contains` a correct
/// insensitive substring test folding one side only would compare a folded needle against an
/// unfolded haystack and miss every accented match.
private static func folded(_ text: String) -> String {
text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
}
}
+76 -20
View File
@@ -109,13 +109,18 @@ public enum SelectionGrammar {
/// (04-interactions.md § Selection: "single click selects the lane (click again to /// (04-interactions.md § Selection: "single click selects the lane (click again to
/// unselect)", and the header "toggles like empty space (settled)"). A card face passes /// unselect)", and the header "toggles like empty space (settled)"). A card face passes
/// `false`: Finder does not deselect a file by clicking it twice, and neither do we. /// `false`: Finder does not deselect a file by clicking it twice, and neither do we.
/// - filter: the live search filter (04 § Search). Only the -branch reads it a range walks
/// what is *on the board*, which under a search is the survivors because the other two
/// name their target outright and a click on something the user can see needs no permission
/// from the predicate.
public static func click( public static func click(
_ target: SelectionTarget, _ target: SelectionTarget,
modifier: ClickModifier, modifier: ClickModifier,
selection: ItemReferenceSet, selection: ItemReferenceSet,
anchor: ItemID?, anchor: ItemID?,
snapshot: BoardModel, snapshot: BoardModel,
togglesOnRepeat: Bool = false togglesOnRepeat: Bool = false,
filter: SearchFilter = .inactive
) -> Outcome { ) -> Outcome {
switch modifier { switch modifier {
case .plain: case .plain:
@@ -123,7 +128,7 @@ public enum SelectionGrammar {
case .command: case .command:
return command(target, selection: selection, snapshot: snapshot) return command(target, selection: selection, snapshot: snapshot)
case .shift: case .shift:
return shift(target, selection: selection, anchor: anchor, snapshot: snapshot) return shift(target, selection: selection, anchor: anchor, snapshot: snapshot, filter: filter)
} }
} }
@@ -197,10 +202,18 @@ public enum SelectionGrammar {
_ target: SelectionTarget, _ target: SelectionTarget,
selection: ItemReferenceSet, selection: ItemReferenceSet,
anchor: ItemID?, anchor: ItemID?,
snapshot: BoardModel snapshot: BoardModel,
filter: SearchFilter
) -> Outcome { ) -> Outcome {
guard let anchor, guard let anchor,
let span = range(from: anchor, to: target.id, kind: target.kind, on: target.side, in: snapshot) let span = range(
from: anchor,
to: target.id,
kind: target.kind,
on: target.side,
in: snapshot,
filter: filter
)
else { else {
return plain(target, selection: selection, togglesOnRepeat: false) return plain(target, selection: selection, togglesOnRepeat: false)
} }
@@ -224,14 +237,20 @@ public enum SelectionGrammar {
/// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain /// (side, kind) pair. The callers differ on what they do with that: a click degrades to a plain
/// click (it names an unambiguous target), while a -arrow goes inert (its next step is /// click (it names an unambiguous target), while a -arrow goes inert (its next step is
/// ambiguous). /// ambiguous).
///
/// **A filtered endpoint is a missing one**, which needs no rule of its own: a card the search
/// hid is absent from the list, so a range aimed at it answers `nil` and each caller degrades
/// exactly as it does for a card an agent deleted. The span between two *visible* endpoints
/// likewise collects only survivors 04 § Search's "ranges read [the filter]".
public static func range( public static func range(
from: ItemID, from: ItemID,
to: ItemID, to: ItemID,
kind: SelectionKind, kind: SelectionKind,
on side: Liveness, on side: Liveness,
in snapshot: BoardModel in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> Set<ItemID>? { ) -> Set<ItemID>? {
let list = order(of: kind, on: side, in: snapshot) let list = order(of: kind, on: side, in: snapshot, filter: filter)
guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil } guard let start = list.firstIndex(of: from), let end = list.firstIndex(of: to) else { return nil }
return Set(start <= end ? list[start...end] : list[end...start]) return Set(start <= end ? list[start...end] : list[end...start])
} }
@@ -241,15 +260,26 @@ public enum SelectionGrammar {
/// The list a -range walks for one (side, kind) pair **the single place a "what's on the /// The list a -range walks for one (side, kind) pair **the single place a "what's on the
/// board, in what order" question is answered** for the pointer. /// board, in what order" question is answered** for the pointer.
/// ///
// m5-search: the filter "is the single source of truth for what's on the board ranges all /// **The search filter threads in here and in `MarqueeTargetRegistry`'s membership, and nowhere
// read it" (04-interactions.md § Search). It threads in here and in `MarqueeTargetRegistry`'s /// else** the filter "is the single source of truth for what's on the board ranges all
// membership, and nowhere else every range and every Select All is stated in terms of these /// read it" (04-interactions.md § Search), and every range, every Select All and every arrow
// four lists. /// walk is stated in terms of these four lists, so one parameter narrows all of them together.
public static func order(of kind: SelectionKind, on side: Liveness, in snapshot: BoardModel) -> [ItemID] { ///
/// It defaults to `.inactive` so the many callers with no query in hand (the drag's flatten
/// order, a lane-index lookup, the successor's container) read exactly as they did before the
/// filter existed; the callers that *are* the board's input grammar pass the store's query.
///
/// **The lane list takes no filter**, because a card query hides no lane see `SearchFilter`.
public static func order(
of kind: SelectionKind,
on side: Liveness,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
switch (side, kind) { switch (side, kind) {
case (.live, .card): liveCards(in: snapshot) case (.live, .card): liveCards(in: snapshot, filter: filter)
case (.live, .lane): liveLanes(in: snapshot) case (.live, .lane): liveLanes(in: snapshot)
case (.trashed, _): trashEntries(of: kind, in: snapshot) case (.trashed, _): trashEntries(of: kind, in: snapshot, filter: filter)
} }
} }
@@ -259,10 +289,14 @@ public enum SelectionGrammar {
/// ///
/// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten /// The snapshot's arrays are already in display order (`Ranks.sortedForDisplay`), so the flatten
/// is one walk `NewCardTarget.resolve`'s walk, in list form. /// is one walk `NewCardTarget.resolve`'s walk, in list form.
public static func liveCards(in snapshot: BoardModel) -> [ItemID] { ///
/// **The filter narrows the walk in place**, which is what makes a search-time -range and
/// Select All read the same board the masonry drew: `LaneView.renderedCards` applies the same
/// predicate to the same cards, one lane at a time, and this is that collection flattened.
public static func liveCards(in snapshot: BoardModel, filter: SearchFilter = .inactive) -> [ItemID] {
var ids: [ItemID] = [] var ids: [ItemID] = []
for lane in snapshot.lanes where !lane.isDeleted { for lane in snapshot.lanes where !lane.isDeleted {
for card in lane.cards where !card.isDeleted { for card in lane.cards where !card.isDeleted && filter.matches(card) {
ids.append(card.id) ids.append(card.id)
} }
} }
@@ -271,6 +305,11 @@ public enum SelectionGrammar {
/// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md § /// Live lanes, left to right. Tombstoned lanes render nowhere on the board (03-board-ui.md §
/// Trash collapses each into one entry), so they are absent from the live lane order entirely. /// Trash collapses each into one entry), so they are absent from the live lane order entirely.
///
/// **No search filter, deliberately**: 04 § Search filters *cards*, and a lane whose body the
/// query empties is still a lane on the board the width division is layout, and the badge
/// showing `0` is the honest report. So the lane domain's ranges, arrows and moves are the one
/// part of the board grammar a search does not narrow.
public static func liveLanes(in snapshot: BoardModel) -> [ItemID] { public static func liveLanes(in snapshot: BoardModel) -> [ItemID] {
snapshot.lanes.filter { !$0.isDeleted }.map(\.id) snapshot.lanes.filter { !$0.isDeleted }.map(\.id)
} }
@@ -286,9 +325,16 @@ public enum SelectionGrammar {
/// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert* /// the deliberate pointer twin of the keyboard's rule: a -arrow onto a lane entry is *inert*
/// because its next step is ambiguous, while a click names an unambiguous same-kind target and /// because its next step is ambiguous, while a click names an unambiguous same-kind target and
/// so the range simply skips. /// so the range simply skips.
public static func trashEntries(of kind: SelectionKind, in snapshot: BoardModel) -> [ItemID] { ///
/// **Filtered like any lane** (03-board-ui.md § Trash) the same predicate `TrashLaneView`
/// applies to the same rows, so a trash-side range walks exactly what the column is showing.
public static func trashEntries(
of kind: SelectionKind,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> [ItemID] {
TrashModel.entries(of: snapshot) TrashModel.entries(of: snapshot)
.filter { $0.isLaneEntry == (kind == .lane) } .filter { $0.isLaneEntry == (kind == .lane) && filter.matches($0) }
.map(\.id) .map(\.id)
} }
@@ -346,7 +392,17 @@ public enum SelectionGrammar {
/// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's /// **Deliberate deletes only.** External vanishing never picks a successor (02-architecture.md's
/// reload-survival rule: "the selection just shrinks"), which is why this is called by /// reload-survival rule: "the selection just shrinks"), which is why this is called by
/// `BoardStore.delete` and by nothing on the reload path. /// `BoardStore.delete` and by nothing on the reload path.
public static func successor(afterDeleting ids: Set<ItemID>, in snapshot: BoardModel) -> ItemID? { ///
/// **The container is what the lane is *showing*.** Under a search the successor must be a card
/// the user can see "nothing invisible stays selected" is the trash's phrasing of a rule the
/// filter obeys too and picking a hidden neighbour would hand the selection straight back to
/// `constrainToSearch(in:)` to drop, which is a deselect wearing a successor's clothes. So the
/// filter narrows the container, and repeated walks down the *filtered* lane.
public static func successor(
afterDeleting ids: Set<ItemID>,
in snapshot: BoardModel,
filter: SearchFilter = .inactive
) -> ItemID? {
guard !ids.isEmpty else { return nil } guard !ids.isEmpty else { return nil }
let selection = ItemReferenceSet(ids: ids, liveness: .live) let selection = ItemReferenceSet(ids: ids, liveness: .live)
guard let kind = kind(of: selection, in: snapshot) else { return nil } guard let kind = kind(of: selection, in: snapshot) else { return nil }
@@ -358,12 +414,12 @@ public enum SelectionGrammar {
case .card: case .card:
// The last selected card in flatten order names the lane; its lane's rendered cards are // The last selected card in flatten order names the lane; its lane's rendered cards are
// the container the successor is drawn from. // the container the successor is drawn from.
guard let last = liveCards(in: snapshot).last(where: { ids.contains($0) }), guard let last = liveCards(in: snapshot, filter: filter).last(where: { ids.contains($0) }),
let lane = snapshot.lanes.first(where: { lane in let lane = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted } !lane.isDeleted && lane.cards.contains { $0.id == last && !$0.isDeleted }
}) })
else { return nil } else { return nil }
container = lane.cards.filter { !$0.isDeleted }.map(\.id) container = lane.cards.filter { !$0.isDeleted && filter.matches($0) }.map(\.id)
} }
let doomed = container.indices.filter { ids.contains(container[$0]) } let doomed = container.indices.filter { ids.contains(container[$0]) }
+60 -4
View File
@@ -339,7 +339,12 @@ public final class TransientBoardState {
/// against whatever snapshot is current, so the filter is never stale, and the selection is kept /// against whatever snapshot is current, so the filter is never stale, and the selection is kept
/// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe /// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe
/// the same rule a reload uses, which is why "hidden cards leave the selection" needs no code /// the same rule a reload uses, which is why "hidden cards leave the selection" needs no code
/// of its own. /// of its own (`constrainToSearch(in:)`).
///
/// **Written through `BoardStore.searchQuery`, not here**, on every path that *narrows* it: the
/// store is what has a snapshot, and narrowing without constraining would leave a selection
/// pointing at cards nobody can see. Widening `beginPlaceholder`'s creation clear, and the
/// clear Escape performs is safe from anywhere, because a bigger universe invalidates nothing.
public var searchQuery: String = "" public var searchQuery: String = ""
// MARK: The inline editors // MARK: The inline editors
@@ -463,10 +468,22 @@ public final class TransientBoardState {
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the /// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
/// focus is being taken by another editor before the user has said they are done. /// focus is being taken by another editor before the user has said they are done.
/// ///
/// **Creation clears the search, and this is the funnel** (04-interactions.md § Search): "a
/// brand-new card must not be born invisible". Every entry point to creation goes through here
/// N, Return on a lane, the lane header's button, a double-click on empty space so the
/// carve-out is stated once instead of four times.
///
/// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate
/// with one exception, not two". A rename committed under an active search re-runs the
/// predicate like any other edit, and a title that stops matching animates its card out and
/// drops it from the selection which falls out of `BoardStore.commitRename`'s ordinary write
/// and the reload's `constrainToSearch(in:)`, with nothing here to arrange it.
///
/// - Parameter anchorCardID: the card the new one is born immediately after (04's N target /// - Parameter anchorCardID: the card the new one is born immediately after (04's N target
/// rule), or `nil` for the lane's bottom which is what Return, the header button, and a /// rule), or `nil` for the lane's bottom which is what Return, the header button, and a
/// double-click on empty space all pass. /// double-click on empty space all pass.
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) { public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
searchQuery = ""
renameEditor = nil renameEditor = nil
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID) newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
noteActiveLane(laneID) noteActiveLane(laneID)
@@ -603,9 +620,13 @@ public final class TransientBoardState {
/// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns /// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
/// both halves. /// both halves.
/// ///
/// `searchQuery` and `isTrashVisible` are deliberately not mentioned below. Neither references /// **`searchQuery` is re-applied rather than re-resolved.** It references no item, so no
/// an item, so no snapshot can invalidate either the query's *results* change with every /// snapshot can invalidate it but its *results* change with every snapshot, and a reload
/// snapshot, which is precisely why the results are not stored here. /// landing under an active query can hide a selected card as surely as a query change can (an
/// agent editing a title out of the match is the case). So `constrainToSearch(in:)` runs last,
/// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
/// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
/// nothing to say here at all.
public func resolve(against snapshot: BoardModel) { public func resolve(against snapshot: BoardModel) {
selection = selection.resolved(against: snapshot) selection = selection.resolved(against: snapshot)
dragMembers = dragMembers.resolved(against: snapshot) dragMembers = dragMembers.resolved(against: snapshot)
@@ -635,6 +656,41 @@ public final class TransientBoardState {
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil } if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil } if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
} }
constrainToSearch(in: snapshot)
}
/// **Hidden cards leave the selection** (04-interactions.md § Search) the constraint rule with
/// the *filter's* universe supplied, which is the second of the two directions
/// `ItemReferenceSet.constrained(to:)`'s doc comment names.
///
/// Called on exactly two occasions, and they are the two ways the visible universe can narrow:
/// when the **query changes** (`BoardStore.searchQuery`'s setter) and when a **reload lands
/// under an active query** (`resolve(against:)` above, whose last line this is). Both hand it
/// the current snapshot, because the predicate has nothing else to run against.
///
/// **A no-op with no search running**, deliberately: with the filter off the visible universe is
/// the whole board, so constraining to it could only ever be the identity and stating that as
/// an early return rather than letting it fall out keeps the reload path free of a board-sized
/// set computation nobody needs.
///
/// The anchor and the head obey the same universe rule the reload gives them, for the same
/// reason: a range origin or a navigation cursor sitting on a card the filter hid would range or
/// step from somewhere the user cannot see. Neither has to stay *in* the selection that
/// asymmetry is `resolve`'s and survives here untouched.
///
/// **The drag and the pending cut are deliberately left alone.** 04 hides cards and says one
/// thing about the consequence that they leave the *selection*. A cut is staged content
/// waiting for a paste that may well happen after the search clears, and a drag under a live
/// filter is a gesture in flight, not a set the filter has any claim on.
public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return }
let universe = filter.visibleIDs(in: snapshot, on: selection.liveness)
selection = selection.constrained(to: universe)
if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
} }
/// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot. /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
+40 -13
View File
@@ -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 // MARK: - Open Card
/// Board Open Card () 11-command-nexus.md's first Board row, and **the one board command /// 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): /// **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 /// / 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 /// field ever sees the key. Which surfaces that covers, and how each is answered, is
/// board popover's fields are covered by disabling while the popover is open at all coarser than /// `caretChordsYield(boardInfo:search:)`'s doc comment shared verbatim with the width pair below.
/// 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.
struct MoveLaneCommands: View { struct MoveLaneCommands: View {
@FocusedValue(\.boardStore) private var store @FocusedValue(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo @FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View { var body: some View {
Button("Move Left") { Button("Move Left") {
move(by: -1) move(by: -1)
} }
.keyboardShortcut(.leftArrow, modifiers: .command) .keyboardShortcut(.leftArrow, modifiers: .command)
.disabled(caretChordsYield || destination(-1) == nil) .disabled(yieldsCaretChords || destination(-1) == nil)
Button("Move Right") { Button("Move Right") {
move(by: 1) move(by: 1)
} }
.keyboardShortcut(.rightArrow, modifiers: .command) .keyboardShortcut(.rightArrow, modifiers: .command)
.disabled(caretChordsYield || destination(1) == nil) .disabled(yieldsCaretChords || destination(1) == nil)
} }
private var caretChordsYield: Bool { private var yieldsCaretChords: Bool {
boardInfo?.isPresented == true caretChordsYield(boardInfo: boardInfo, search: search)
} }
/// The sole selected live lane and the display slot one step would put it in `nil` when there /// 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(\.boardStore) private var store
@FocusedValue(\.boardInfo) private var boardInfo @FocusedValue(\.boardInfo) private var boardInfo
@FocusedValue(\.boardSearch) private var search
var body: some View { var body: some View {
Button("Increase Lane Width") { Button("Increase Lane Width") {
step(by: 1) step(by: 1)
} }
.keyboardShortcut(.rightArrow, modifiers: [.option, .command]) .keyboardShortcut(.rightArrow, modifiers: [.option, .command])
.disabled(caretChordsYield || selectedLanes.isEmpty) .disabled(yieldsCaretChords || selectedLanes.isEmpty)
Button("Decrease Lane Width") { Button("Decrease Lane Width") {
step(by: -1) step(by: -1)
} }
.keyboardShortcut(.leftArrow, modifiers: [.option, .command]) .keyboardShortcut(.leftArrow, modifiers: [.option, .command])
.disabled(caretChordsYield || !canDecrease) .disabled(yieldsCaretChords || !canDecrease)
} }
private var caretChordsYield: Bool { private var yieldsCaretChords: Bool {
boardInfo?.isPresented == true caretChordsYield(boardInfo: boardInfo, search: search)
} }
/// The selected live lanes, in snapshot order the batch, and the items' validation. /// The selected live lanes, in snapshot order the batch, and the items' validation.
+241
View File
@@ -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
}
}
+95 -18
View File
@@ -38,10 +38,19 @@ import SwiftUI
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as /// - **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). /// 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 /// ### What is deliberately not here yet
/// ///
/// The toolbar and search belong to later milestone cards; so do external Finder file drops, which /// External Finder file drops join the very drop delegates this file already attaches (see
/// join the very drop delegates this file already attaches (see `BoardDrops.swift`). /// `BoardDrops.swift`).
struct BoardView: View { struct BoardView: View {
let store: BoardStore 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. /// needs the board's own window ref, which is the host's identity and not the board's.
let openCard: (ItemID) -> Void 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 § /// The app-wide quick-style recents, for the board-anchored style editor (03-board-ui.md §
/// Styling Controls). /// Styling Controls).
@Environment(AppModel.self) private var appModel @Environment(AppModel.self) private var appModel
@@ -161,7 +175,15 @@ struct BoardView: View {
.focusable() .focusable()
.focusEffectDisabled() .focusEffectDisabled()
.focused($isBoardFocused) .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 .onChange(of: store.isEditingInline) { _, editing in
// An editor took focus and has now given it back. Without this the strip stays unfocused // An editor took focus and has now given it back. Without this the strip stays unfocused
// after every rename and Return silently stops working. // 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 // 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). // drawn beside it is never inside an animated transaction (03 § Motion again).
.animation(Motion.dragReflow(reduced: reduceMotion), value: stripProposal) .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 /// The rubber band itself: a translucent accent fill with a hairline border, in strip
@@ -409,6 +444,18 @@ struct BoardView: View {
store.transient.isTrashVisible 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 // MARK: - The drag
/// What each of this window's drop targets and its lanes' autoscroll drivers is handed. /// 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 /// **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 /// **The search takes Escape before its clear-selection meaning** (04 § Search, settled): "with
/// it slots between these two once the search field exists. /// *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 /// 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. /// both kinds because at most one can be open at a time.
private func handleEscape() -> KeyPress.Result { private func handleEscape() -> KeyPress.Result {
@@ -579,6 +634,10 @@ struct BoardView: View {
store.transient.discardRename() store.transient.discardRename()
return .handled return .handled
} }
if !store.searchQuery.isEmpty {
store.clearSearch()
return .handled
}
guard !store.selection.isEmpty else { return .ignored } guard !store.selection.isEmpty else { return .ignored }
store.clearSelection() store.clearSelection()
return .handled return .handled
@@ -657,6 +716,9 @@ struct BoardView: View {
/// The **trash's list is both kinds interleaved** (`TrashModel.entries`), because "arrows walk /// 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 /// 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. /// 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)? { private func arrowOrigin() -> (head: ItemID, side: Liveness, isLaneDomain: Bool)? {
let selection = store.selection let selection = store.selection
guard !selection.isEmpty else { return nil } guard !selection.isEmpty else { return nil }
@@ -667,10 +729,10 @@ struct BoardView: View {
case .live: case .live:
guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil } guard let kind = SelectionGrammar.kind(of: selection, in: store.snapshot) else { return nil }
isLaneDomain = kind == .lane 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: case .trashed:
isLaneDomain = false isLaneDomain = false
list = TrashModel.entries(of: store.snapshot).map(\.id) list = trashEntries.map(\.id)
} }
if let head = store.transient.selectionHead, list.contains(head) { if let head = store.transient.selectionHead, list.contains(head) {
@@ -692,7 +754,7 @@ struct BoardView: View {
if mode == .jump, direction == .left || direction == .right { if mode == .jump, direction == .left || direction == .right {
return jumpToEndLane(direction) 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) replaceSelection(with: first, on: .live)
return .handled return .handled
} }
@@ -763,7 +825,10 @@ struct BoardView: View {
to: next.id, to: next.id,
kind: next.kind, kind: next.kind,
on: next.side, 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 } ) else { return .handled }
store.select(ids, liveness: next.side, anchor: anchor, head: next.id) store.select(ids, liveness: next.side, anchor: anchor, head: next.id)
return .handled return .handled
@@ -785,13 +850,16 @@ struct BoardView: View {
var lane: ItemID? var lane: ItemID?
switch side { switch side {
case .trashed: case .trashed:
container = TrashModel.entries(of: store.snapshot).map(\.id) container = trashEntries.map(\.id)
case .live: case .live:
guard let home = store.snapshot.lanes.first(where: { lane in guard let home = store.snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted } !lane.isDeleted && lane.cards.contains { $0.id == head && !$0.isDeleted }
}) else { return .handled } }) else { return .handled }
lane = home.id 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 } 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 /// 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. /// 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 { 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) replaceSelection(with: first.id, on: .trashed)
return .handled return .handled
} }
let lanes = liveLanes 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 let target = direction == .right
? Self.firstCard(scanning: lanes.reversed()) ? Self.firstCard(scanning: lanes.reversed(), filter: filter)
: Self.firstCard(scanning: lanes) : Self.firstCard(scanning: lanes, filter: filter)
guard let target else { return .handled } guard let target else { return .handled }
replaceSelection(with: target, on: .live) replaceSelection(with: target, on: .live)
return .handled 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" /// 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. /// 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 { 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 return nil
} }
+9 -3
View File
@@ -583,11 +583,17 @@ struct LaneView: View {
/// layout that re-admitted them on every flip would flap the board under the cursor /// 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. /// (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 /// **A card the live search filter hides renders nowhere either** (04-interactions.md § Search):
/// honest for free see `countBadge`. /// "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] { private var renderedCards: [Card] {
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL) 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 // MARK: - Selection
+13
View File
@@ -91,6 +91,19 @@ extension View {
/// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives /// 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 /// in the band's own points come from a drag gesture in the same space, so no conversion
/// happens anywhere. /// 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 @MainActor
func marqueeTarget( func marqueeTarget(
_ id: ItemID, _ id: ItemID,
+10 -6
View File
@@ -34,8 +34,9 @@ import SwiftUI
/// ///
/// ### What is still a later card's /// ### What is still a later card's
/// ///
/// The **search filter** ("shown, it participates in the filter like any lane") and **C copy-out** /// **C copy-out** is still owed. The **search filter** ("shown, it participates in the filter like
/// are still owed. The *pointer* grammar is here: a row's click runs the same `SelectionGrammar` the /// 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 /// 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 /// 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 /// **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. /// The rows the column shows.
/// ///
// m5-search: the shown trash "participates in the filter like any lane", so the search predicate /// **The shown trash "participates in the filter like any lane"** (03-board-ui.md § Trash), so
// narrows this collection exactly as it narrows `LaneView.renderedCards` and the count badge /// the search predicate narrows this collection exactly as it narrows `LaneView.renderedCards`
// follows for free, because it reads this same value. /// 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] { 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 // MARK: - Header
+20 -10
View File
@@ -109,8 +109,14 @@ enum Motion {
/// restore reads like the search filter leavers and arrivers run their transition, survivors /// restore reads like the search filter leavers and arrivers run their transition, survivors
/// reflow under one gentle spring". /// 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 search filter is its call site** (`BoardView.laneStrip`), where it wraps a transaction
/// the figure is settled, and a duration that has nowhere to live gets spelled at a call site. /// 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? { static func contentReflow(reduced: Bool) -> Animation? {
reduced ? nil : .smooth(duration: Duration.contentReflow) reduced ? nil : .smooth(duration: Duration.contentReflow)
} }
@@ -145,8 +151,9 @@ enum Motion {
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane) reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
} }
/// A card arriving or leaving a create, a delete, a Put Back, and (m5) a search filter's /// A card arriving or leaving a create, a delete, a Put Back, and the search filter's leavers
/// leavers and arrivers. The trash's rows wear it too: they are cards, and 10 requires the trash /// 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. /// animations to have a reduced variant like everything else.
static func cardTransition(reduced: Bool) -> AnyTransition { static func cardTransition(reduced: Bool) -> AnyTransition {
cardAppearance(reduced: reduced).transition cardAppearance(reduced: reduced).transition
@@ -243,12 +250,15 @@ enum Motion {
/// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is /// The thin wrapper `BoardStore.land` hands to `withAnimation`: the voice a landing snapshot is
/// applied in, or `nil` for the reloads that snap. /// applied in, or `nil` for the reloads that snap.
/// ///
// m5-drag, m5-search: the voice is the *general* structural spring for every app-mediated // m5-drag: the voice is the *general* structural spring for every app-mediated reload, because
// reload, because the reload seam knows an operation echoed but not which one 03 gives delete // the reload seam knows an operation echoed but not which one 03 gives delete 0.25 s and a
// 0.25 s and a drop commit its own dialect, and neither is reachable from an origin tag. The // drop commit its own dialect, and neither is reachable from an origin tag. The per-operation
// per-operation figures (`delete`, `dragReflow`) become reachable when the operations that own // figures (`delete`, `dragReflow`) become reachable when the operations that own them run their
// them run their own transactions around the gesture, which is m5's card; this seam stays the // own transactions around the gesture; this seam stays the floor under them.
// 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? { static func reloadAnimation(origin: WatchOrigin, endsBracketedOperation: Bool, reduced: Bool) -> Animation? {
guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil } guard reloadAnimates(origin: origin, endsBracketedOperation: endsBracketedOperation) else { return nil }
return structural(reduced: reduced) return structural(reduced: reduced)
+551
View File
@@ -0,0 +1,551 @@
import Foundation
import Testing
@testable import Kanban
/// 04-interactions.md § Search, from the predicate outward.
///
/// The design gives the filter one sentence of behaviour and one sentence of *reach*, and the two
/// need different kinds of test. The predicate is pure, so the first suite below reads like a truth
/// table folding, title-or-body, the empty query, and the scope line that keeps attachment
/// filenames out. The reach is the interesting half: "the filter is the single source of truth for
/// what's on the board ranges, arrow nav, and lane count badges all read it", which is a claim
/// about the *order lists* and about the selection, and the second and third suites pin it there.
///
/// The boards are **real loads off real temp trees**, as in `SelectionGrammarTests`: every rule here
/// reads a body, an `isDeleted`, an attachment listing or `TrashModel`'s sort, and a hand-built
/// `BoardModel` would let all four drift from what the loader actually produces. `WriterFixture`,
/// `Ident` and `Item` live in `WriterTestSupport.swift`.
// MARK: - Fixtures
private enum More {
static let card5 = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
static let laneX = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
}
/// A card or lane whose title and body are **independently controlled** which `Item.rich` cannot
/// be, since its body quotes its title, and a title-or-body test needs the two to disagree.
private func item(order: String, title: String?, body: String) -> String {
var lines = ["---", "schema: 1"]
if let title { lines.append("title: \(title)") }
lines.append("order: \(order)")
lines.append("---")
return lines.joined(separator: "\n") + "\n" + body + "\n"
}
private func tombstoned(
order: String,
title: String,
body: String,
deleted: String = "2026-03-05T10:00:00Z"
) -> String {
"""
---
schema: 1
title: \(title)
order: \(order)
deleted: \(deleted)
---
\(body)
"""
}
/// Three lanes and five cards, built so every clause of the predicate has a card that isolates it:
///
/// | card | title | body | what it proves |
/// |---|---|---|---|
/// | `card1` | Fix login | (no "login") | the title half, and case folding |
/// | `card2` | Résumé polish | (no match) | diacritic folding |
/// | `card3` | *untitled* | "the login service." | the body half, with no title at all |
/// | `card4` | Unrelated | "Nothing here." | the miss plus an attachment named `budget.csv` |
/// | `card5` | Archive | "Old material." | a second miss, in a third lane |
///
/// A query of `login` therefore leaves exactly `card1` and `card3` standing, in two different lanes
/// which is what makes a flatten-order range across the gap worth asserting.
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
item(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
item(order: "2048", title: "Résumé polish", body: "Tighten the wording.")
)
try fixture.item(Ident.lane2, item(order: "2048", title: "Doing", body: "Work in progress."))
try fixture.item(
"\(Ident.lane2)/\(Ident.card3)",
item(order: "1024", title: nil, body: "Deploy the login service.")
)
try fixture.item(
"\(Ident.lane2)/\(Ident.card4)",
item(order: "2048", title: "Unrelated", body: "Nothing here.")
)
// A real attachment, so the scope rule is tested against a card the loader really did list files
// for rather than against an empty array that would pass by accident.
try fixture.file("\(Ident.lane2)/\(Ident.card4)/attachments/budget.csv", Data("a,b\n".utf8))
try fixture.item(Ident.lane3, item(order: "3072", title: "Done", body: "Shipped."))
try fixture.item(
"\(Ident.lane3)/\(More.card5)",
item(order: "1024", title: "Archive", body: "Old material.")
)
return fixture
}
/// A trash holding both row kinds, one of each matching `login` the shape "participates in the
/// filter like any lane" needs, since a lane entry is filtered by its *own* title and body.
///
/// `TrashModel`'s sort is newest first, so the row order is `[card1, laneX, card2]`.
@MainActor
private func makeTrashBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, item(order: "1024", title: "Todo", body: "Inbox lane."))
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "Auth.", deleted: "2026-03-05T10:00:00Z")
)
try fixture.item(
"\(Ident.lane1)/\(Ident.card2)",
tombstoned(order: "2048", title: "Polish", body: "Wording.", deleted: "2026-03-05T06:00:00Z")
)
try fixture.item(
More.laneX,
tombstoned(order: "2048", title: "Old login lane", body: "Retired.", deleted: "2026-03-05T08:00:00Z")
)
return fixture
}
private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)
private let card5 = ItemID(rawValue: More.card5)
private let laneX = ItemID(rawValue: More.laneX)
private func load(_ fixture: WriterFixture) throws -> BoardModel {
try BoardLoader.load(boardRoot: fixture.root).model
}
private func card(_ id: ItemID, in model: BoardModel) throws -> Card {
try #require(model.lanes.flatMap(\.cards).first { $0.id == id })
}
/// One reload, start to settled the only way transient state gets re-resolved.
@MainActor
private func reload(_ store: BoardStore, origin: WatchOrigin = .foreign) async {
store.handleWatcherEvent(.treeChanged(origin))
await store.awaitQuiescence()
}
// MARK: - The predicate
@MainActor
@Suite("SearchFilter — the predicate")
struct SearchFilterPredicateTests {
@Test("An empty query is no filter at all: every card matches and nothing is hidden")
func emptyQueryShowsEverything() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "")
#expect(!filter.isActive)
#expect(SearchFilter.inactive == filter)
for card in model.lanes.flatMap(\.cards) {
#expect(filter.matches(card))
}
}
@Test("Whitespace is a substring like any other — only the empty string turns the filter off")
func whitespaceIsALegitimateQuery() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// "Trim the query" is a rule the design does not state, and a live filter shows the result of
// every keystroke so a trailing space mid-word narrows rather than resetting to everything.
let filter = SearchFilter(query: "fix ")
#expect(filter.isActive)
#expect(filter.matches(try card(card1, in: model)))
#expect(!filter.matches(try card(card4, in: model)))
}
@Test("Matching is case-insensitive, in either direction")
func caseInsensitive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let first = try card(card1, in: try load(fixture))
#expect(SearchFilter(query: "FIX LOGIN").matches(first))
#expect(SearchFilter(query: "fix login").matches(first))
#expect(SearchFilter(query: "AUTH FLOW").matches(first))
}
@Test("Matching is diacritic-insensitive, in either direction")
func diacriticInsensitive() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let polish = try card(card2, in: try load(fixture))
// Both directions, because folding one side only would pass the first and fail the second.
#expect(SearchFilter(query: "resume").matches(polish))
#expect(SearchFilter(query: "RÉSUMÉ").matches(polish))
#expect(SearchFilter(query: "Résumé").matches(polish))
}
@Test("A card matches on its title OR its body; missing both is what hides it")
func titleOrBody() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "login")
// The title half: card1's body says nothing about logins.
let byTitle = try card(card1, in: model)
#expect(byTitle.title.value == "Fix login")
#expect(!byTitle.body.contains("login"))
#expect(filter.matches(byTitle))
// The body half, on a card with no title at all "Untitled" is a rendering, never a value,
// so nothing about the placeholder can be searched.
let byBody = try card(card3, in: model)
#expect(byBody.title.value == nil)
#expect(filter.matches(byBody))
#expect(!SearchFilter(query: "untitled").matches(byBody))
// The miss: "cards whose title *and* body both miss the query animate out".
#expect(!filter.matches(try card(card4, in: model)))
}
@Test("Attachment filenames are not searched — scope is title + body only")
func attachmentNamesAreOutOfScope() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let unrelated = try card(card4, in: try load(fixture))
// The listing is real, so the exclusion below is a decision rather than an empty array.
#expect(unrelated.attachments == ["budget.csv"])
#expect(!SearchFilter(query: "budget").matches(unrelated))
#expect(!SearchFilter(query: "csv").matches(unrelated))
}
@Test("A lane matches by its own title and body — the trash's rows, not the board's lanes")
func lanesMatchByTheirOwnText() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(SearchFilter(query: "todo").matches(todo))
#expect(SearchFilter(query: "inbox").matches(todo))
// A lane is not matched through its cards: `card1` says "login", the lane does not.
#expect(!SearchFilter(query: "login").matches(todo))
}
@Test("The visible universe keeps every live lane and only the matching cards")
func visibleUniverse() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let visible = SearchFilter(query: "login").visibleIDs(in: model, on: .live)
// Lanes are never hidden by a card query a lane the filter empties is still a lane on the
// board, so a lane selection survives a query that empties its body.
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
#expect(visible.intersection([card1, card2, card3, card4, card5]) == [card1, card3])
}
}
// MARK: - The order lists
@MainActor
@Suite("SearchFilter — the order lists")
struct SearchFilterOrderTests {
@Test("The live card order narrows to the survivors, in flatten order")
func liveCardsNarrow() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.liveCards(in: model) == [card1, card2, card3, card4, card5])
#expect(SelectionGrammar.liveCards(in: model, filter: SearchFilter(query: "login")) == [card1, card3])
#expect(SelectionGrammar.order(
of: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@Test("The lane order is untouched by a query — a card filter hides no lane")
func laneOrderIsUnfiltered() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// `lane3`'s only card misses the query, and the lane is still in the order: the width
// division is layout, and a `0` badge is the honest report.
#expect(SelectionGrammar.order(
of: .lane,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [lane1, lane2, lane3])
}
@Test("A ⇧-range under a query spans only the survivors between its endpoints")
func rangesWalkTheFilteredBoard() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// Unfiltered, card1 card3 sweeps card2 up with it.
#expect(SelectionGrammar.range(from: card1, to: card3, kind: .card, on: .live, in: model)
== [card1, card2, card3])
// Filtered, the span between the same two endpoints is the two that are on screen.
#expect(SelectionGrammar.range(
from: card1,
to: card3,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == [card1, card3])
}
@Test("A hidden endpoint is a missing one: the range degrades exactly as it does for a deleted card")
func aHiddenEndpointHasNoRange() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.range(
from: card1,
to: card2,
kind: .card,
on: .live,
in: model,
filter: SearchFilter(query: "login")
) == nil)
}
@Test("Trash entries filter like any lane — card rows and lane rows alike, by their own text")
func trashEntriesNarrow() throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
#expect(SelectionGrammar.trashEntries(of: .card, in: model) == [card1, card2])
#expect(SelectionGrammar.trashEntries(of: .lane, in: model) == [laneX])
let filter = SearchFilter(query: "login")
#expect(SelectionGrammar.trashEntries(of: .card, in: model, filter: filter) == [card1])
// The lane row matches on its *own* title, not on the card buried inside it.
#expect(SelectionGrammar.trashEntries(of: .lane, in: model, filter: filter) == [laneX])
#expect(SelectionGrammar.trashEntries(
of: .lane,
in: model,
filter: SearchFilter(query: "polish")
).isEmpty)
}
@Test("The delete successor is drawn from what the lane is showing")
func successorSkipsHiddenSiblings() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
// Unfiltered, deleting the untitled card lands on its lane's next card.
#expect(SelectionGrammar.successor(afterDeleting: [card3], in: model) == card4)
// Under `login`, card4 is hidden and there is nothing else visible in that lane, so the
// honest answer is nothing rather than a card the query animated out.
#expect(SelectionGrammar.successor(
afterDeleting: [card3],
in: model,
filter: SearchFilter(query: "login")
) == nil)
}
}
// MARK: - The store seam
@MainActor
@Suite("SearchFilter — the board's universe")
struct SearchFilterStoreTests {
@Test("Select All under a query selects the visible cards only")
func selectAllIsFilterRespecting() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.selectAll()
#expect(store.selection.ids == [card1, card2, card3, card4, card5])
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1, card3])
}
@Test("Select All on the trash side reads the filter too")
func selectAllInTheTrashIsFilterRespecting() async throws {
let fixture = try makeTrashBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.transient.isTrashVisible = true
store.select([card2], liveness: .trashed)
store.selectAll()
#expect(store.selection.ids == [card1, card2])
store.select([card1], liveness: .trashed)
store.searchQuery = "login"
store.selectAll()
#expect(store.selection.ids == [card1])
}
@Test("Hidden cards leave the selection the moment the query narrows — anchor and head with them")
func aQueryChangeConstrainsTheSelection() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([card1, card2], liveness: .live, anchor: card1, head: card2)
store.searchQuery = "login"
#expect(store.selection.ids == [card1])
// The anchor is still visible, so a -click still ranges from it; the head was hidden, so
// the arrows re-derive from the set's last member on the next press.
#expect(store.transient.selectionAnchor == card1)
#expect(store.transient.selectionHead == nil)
}
@Test("A lane selection survives a query that empties the lane")
func laneSelectionsSurviveTheFilter() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.select([lane3], liveness: .live)
store.searchQuery = "login"
#expect(store.selection.ids == [lane3])
}
@Test("Clearing the query widens the board and disturbs nothing")
func clearingTheSearchRestoresTheBoard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.clearSearch()
#expect(store.searchQuery.isEmpty)
#expect(store.selection.ids == [card1])
#expect(SelectionGrammar.liveCards(in: store.snapshot, filter: store.searchFilter).count == 5)
}
@Test("A reload landing under an active query re-applies the filter to the selection")
func aReloadUnderAQueryConstrains() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1, card3], liveness: .live, anchor: card1, head: card3)
// An agent edits the title out of the match. The card is still there this is not a vanish,
// so only the *filter's* universe can eject it.
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
item(order: "1024", title: "Fix auth", body: "The auth flow breaks on retry.")
)
await reload(store)
#expect(store.snapshot.lanes.flatMap(\.cards).contains { $0.id == card1 })
#expect(store.selection.ids == [card3])
#expect(store.transient.selectionAnchor == nil)
#expect(store.transient.selectionHead == card3)
}
@Test("Creating a card clears the search — a brand-new card must not be born invisible")
func creationClearsTheSearch() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
// The one funnel every creation entry point goes through N, Return on a lane, the header
// button, a double-click on empty space.
store.transient.beginPlaceholder(inLane: lane3)
#expect(store.searchQuery.isEmpty)
#expect(store.transient.newCardPlaceholder?.laneID == lane3)
}
@Test("Rename gets no carve-out: the query stands, and a card renamed out of it leaves the selection")
func renameDoesNotClearTheSearch() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
#expect(store.searchQuery == "login")
store.transient.updateRenameDraft("Fix auth")
store.commitRename()
await reload(store, origin: .appMediated)
// "A title that stops matching animates the card out and drops it from the selection,
// exactly as an agent's edit would" and the filter stays a pure predicate, so nothing here
// is arranged by the rename path itself.
#expect(store.searchQuery == "login")
#expect(try card(card1, in: store.snapshot).title.value == "Fix auth")
#expect(store.selection.isEmpty)
}
@Test("A rename that keeps the card matching keeps it selected")
func aStillMatchingRenameKeepsItsCard() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login again")
store.commitRename()
await reload(store, origin: .appMediated)
#expect(store.selection.ids == [card1])
}
@Test("Deleting under a query walks the filtered lane")
func deleteSelectsAVisibleSuccessor() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// `Todo` shows both its cards under this query, so the successor is the visible neighbour.
store.searchQuery = "the"
store.select([card1], liveness: .live)
store.delete([card1])
#expect(store.selection.ids == [card2])
}
}
+2
View File
@@ -26,6 +26,8 @@ Lanework is in early development. This list tracks what has actually shipped and
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live. - **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
- **Live search** — the board window's toolbar carries one item, a search field (Edit ▸ Find, ⌘F), and typing in it filters the board as you type: a card stays when its title *or* its body contains the query, case- and diacritic-insensitively (so `resume` finds "Résumé"), and everything else animates out under one gentle spring while the survivors reflow. Scope is title and body only — attachment filenames are deliberately not searched. The filter is the single source of truth for what's on the board rather than a highlight over it: the masonry, each lane's count badge, drop zones, the rubber band, ⇧-ranges, Select All, arrow navigation and the ⌥-jumps all read it, and the shown trash filters like any other lane, its card rows and lane rows each by their own text. Nothing invisible stays selected — a card the query hides leaves the selection the moment it goes, and so does one an agent edits out of the match while you search. The field is a control, not an editor: board commands stay live and act on the selection while you type (⌘N included, which clears the search first so a new card is never born invisible), only ⌘←/⌘→ and ⌥⌘←/⌥⌘→ stand down so they stay caret chords, plain ⌫ edits the query while ⌘⌫ still deletes the selection, and Return is swallowed because a live filter has nothing to submit. Tab hands the keyboard to the board with the query intact. Escape steps out one layer per press — a non-empty field clears, an empty one returns focus to the board, and a board-focused Escape under an active search clears it before it means deselect. A rename is deliberately not a carve-out: rename a card out of the match during a search and it animates away exactly as an agent's edit would.
- **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu. - **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu.
- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched. - **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — tombstoned items carried, strays and timestamps untouched.