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
125 lines
6.4 KiB
Swift
125 lines
6.4 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - The modifier a click carried
|
|
|
|
extension ClickModifier {
|
|
|
|
/// The modifier the keyboard is holding **right now**, reduced to the grammar's three cases.
|
|
///
|
|
/// Read from `NSEvent.modifierFlags` rather than from the gesture value, because SwiftUI's
|
|
/// `TapGesture` hands its handler nothing about the event — and `EventModifiers` on a
|
|
/// `.modifiers(_:)`-qualified gesture would need one recogniser per modifier, three of which
|
|
/// would then race to consume the same click.
|
|
///
|
|
/// **⌘ wins over ⇧** when both are down: 04-interactions.md gives the two no combined meaning
|
|
/// ("⌘-click toggles; ⇧-click range-extends"), so the reduction happens once, here, and no call
|
|
/// site re-decides it.
|
|
@MainActor
|
|
static var current: ClickModifier {
|
|
let flags = NSEvent.modifierFlags
|
|
if flags.contains(.command) { return .command }
|
|
if flags.contains(.shift) { return .shift }
|
|
return .plain
|
|
}
|
|
}
|
|
|
|
// MARK: - The rubber band's gesture
|
|
|
|
/// What a board window lends its empty surfaces so each can be a rubber band: the one session, the
|
|
/// one target registry, and the store the band selects into.
|
|
///
|
|
/// `BoardDropContext`'s sibling in role — the strip owning state that a leaf gesture needs — but a
|
|
/// value rather than a pair of closures, because all three surfaces (lane empty space, the board
|
|
/// backdrop, the trash column) want the *same* gesture rather than three variations threaded with
|
|
/// different geometry. Only the side differs, and that is the parameter.
|
|
@MainActor
|
|
struct MarqueeControl {
|
|
|
|
let session: MarqueeSession
|
|
let registry: MarqueeTargetRegistry
|
|
let store: BoardStore
|
|
|
|
/// The band, as one gesture attached with `simultaneousGesture` wherever empty space is.
|
|
///
|
|
/// - **The begin guard is geometric**: a drag whose start lands inside a registered frame is
|
|
/// somebody else's (a card drag, a drag out of the trash), so no band begins and the sample
|
|
/// loop simply keeps declining for the rest of that drag. Deciding this by frames rather than
|
|
/// by gesture priority is what keeps the two from fighting, and it stays correct as the
|
|
/// masonry reflows.
|
|
/// - **The side is fixed at the origin** — 04-interactions.md ▸ The trash's rule, stored in the
|
|
/// session so a band dragged across the boundary keeps its meaning.
|
|
/// - **Live-updating, not commit-on-release**: each sample recomputes the whole set from the
|
|
/// band, so the selection follows the cursor both ways. An empty band clears rather than
|
|
/// leaving the last non-empty one standing.
|
|
/// - **Alive under the read-only lock**: selection is not a mutation (02-architecture.md § The
|
|
/// lock's scope), and no `isEditingInline` guard either — a click-away mid-rename already
|
|
/// commits through the field's own focus loss.
|
|
func gesture(side: Liveness) -> some Gesture {
|
|
DragGesture(minimumDistance: MarqueeSession.minimumDistance, coordinateSpace: .named(BoardView.stripSpace))
|
|
.onChanged { value in
|
|
if !session.isActive {
|
|
guard !registry.contains(value.startLocation) else { return }
|
|
session.begin(at: value.startLocation, side: side)
|
|
}
|
|
session.update(to: value.location)
|
|
guard let rect = session.rect else { return }
|
|
let ids = MarqueeMath.selection(rect: rect, targets: registry.all, side: session.side)
|
|
if ids.isEmpty {
|
|
store.clearSelection()
|
|
} else {
|
|
// Neither cursor: a band names no click to range from and no item to arrow from,
|
|
// so a ⇧-click after one acts plain and an arrow re-derives a position from the
|
|
// set's last member (`TransientBoardState.selectionAnchor`, `selectionHead`).
|
|
// Both are spelled out rather than defaulted, because `select`'s sole-member
|
|
// default would otherwise pick one up the moment a band happened to sweep
|
|
// exactly one card.
|
|
store.select(ids, liveness: session.side, anchor: nil, head: nil)
|
|
}
|
|
}
|
|
.onEnded { _ in session.end() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Registering a sweepable frame
|
|
|
|
extension View {
|
|
|
|
/// Keeps this item's drawn frame in the window's marquee registry, and takes it out again when
|
|
/// the view goes away.
|
|
///
|
|
/// The frame is measured in `BoardView.stripSpace`, the one space every marquee coordinate lives
|
|
/// in — the band's own points come from a drag gesture in the same space, so no conversion
|
|
/// happens anywhere.
|
|
///
|
|
/// **This is also how the search filter reaches the band and the arrows** (04-interactions.md
|
|
/// § Search, "marquee, … arrow nav … all read it"): a card the filter hides is never built, so
|
|
/// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the
|
|
/// masonry rather than re-running the predicate.
|
|
///
|
|
/// One bounded honesty about that: a card leaving under the filter's transition stays registered
|
|
/// until the transition ends (`onDisappear` fires when the view really goes, not when the query
|
|
/// changed), so for the length of one content-reflow spring a fading card is still sweepable and
|
|
/// still an arrow's neighbour. It is on screen for exactly that span, and it has already left the
|
|
/// selection (`TransientBoardState.constrainToSearch(in:)` runs at the keystroke), so the window
|
|
/// is visible rather than phantom — accepted rather than closed by teaching three input sites a
|
|
/// predicate the layout already applied.
|
|
@MainActor
|
|
func marqueeTarget(
|
|
_ id: ItemID,
|
|
kind: SelectionKind,
|
|
side: Liveness,
|
|
in registry: MarqueeTargetRegistry
|
|
) -> some View {
|
|
// The space name is read here, on the main actor, rather than inside the measuring closure:
|
|
// `BoardView` is main-actor-isolated by its `View` conformance, and the closure is not.
|
|
let space = BoardView.stripSpace
|
|
return onGeometryChange(for: CGRect.self) { proxy in
|
|
proxy.frame(in: .named(space))
|
|
} action: { frame in
|
|
registry.update(MarqueeTarget(id: id, kind: kind, side: side, frame: frame))
|
|
}
|
|
.onDisappear { registry.remove(id) }
|
|
}
|
|
}
|