Realign search, clipboard, and lane-hover code with the second batch

Creation now clears the search by mechanism, not gesture: one seam
(noteUserCreation) states 04's rule once, called from the placeholder
funnel, paste — cards and lanes, after the staleness guard so a stale
paste clears nothing — and Finder file-drop creation; the attach path
deliberately doesn't clear, and cross-board arrivals and New Lane stay
outside the seam (a transfer isn't creation; a lane can't be born
invisible). An open inline rename now survives the filter hiding its
card: the model already kept the editor, but the field renders in the
card's slot, so renderedCards keeps the renaming card's slot exactly as
long as the editor is open — the query stands throughout, and commit or
Escape lets the predicate apply in the same pass. Verified conformant
and newly pinned: query-emptied lanes keep their slot with a 0 badge,
pasteboard staleness (takeover before paste and mid-staging both no-op),
out-transition reachability and the strip pre-divide hold by
construction with comments citing their rulings.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:45:12 -04:00
parent 524488122f
commit 5c0c0e5619
9 changed files with 423 additions and 21 deletions
+18 -1
View File
@@ -505,7 +505,8 @@ struct BoardView: View {
/// simply divides across one more unit and every lane compresses.
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
/// from the destination's side: the shadow occupies its units, and the strip has to make room
/// for them or the shadow would be drawn at a width the lane will not have.
/// for them or the shadow would be drawn at a width the lane will not have
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
@@ -519,6 +520,22 @@ struct BoardView: View {
/// The units a cross-board lane run would add to this strip while its shadow is proposed here;
/// zero for a within-board drag, whose lanes are already counted.
///
/// **A cross-board lane arrival pre-divides the destination strip during hover**
/// (04-interactions.md Drag and drop, settled): "while a foreign lane drag proposes into a
/// board, the destination's standard width is computed with the arriving run's units included, so
/// the shadow draws at the width the lane will actually take without this it overflows the
/// strip (the pathfinder's `stripWidthUnits`)". The units are the run's own, frozen at pickup by
/// the *source* board (`DragSession.laneUnits`) both boards are open in this app, so nothing
/// has to cross the pasteboard for the destination to know how wide its visitor is.
///
/// Gated on a live proposal *on this board* rather than on hover alone, which is the same bullet's
/// accepted residue: "the first entry samples the un-widened standard for one frame before
/// hysteresis settles accepted, imperceptible".
///
/// One number, two readers: body evaluation draws the strip and the shadow with it
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
/// so the zones can never disagree with what is on screen.
private var arrivingLaneUnits: Int {
let session = appModel.dragSession
guard session.isDraggingLanes,
+44 -4
View File
@@ -298,7 +298,14 @@ struct LaneView: View {
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search
/// filter like every other surface during a search it shows the visible count, not the
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
/// follows by construction, with no second rule to keep in step.
/// follows by construction, with no second rule to keep in step. **A lane the query empties
/// shows `0` and keeps its slot** (04-interactions.md § Search, settled: "lanes are never
/// filtered out the search filters cards, and the board's structure is not a search result")
/// which is `BoardView.liveLanes` never consulting the filter at all, made visible here.
///
/// The rename exemption rides along for the same reason every other rule does: the badge counts
/// what the body renders, and while an inline rename is open its card is one of the things the
/// body renders (see `renderedCards`).
private var countBadge: some View {
Text("\(renderedCards.count)")
.font(.caption)
@@ -708,10 +715,43 @@ struct LaneView: View {
/// `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.
///
/// **With one exception, and it is the open inline rename** (04-interactions.md § Search,
/// settled): "an open inline rename survives the filter hiding its card the editor is a surface
/// the filter doesn't reach; it stays open and focused, commits by UUID wherever the card lives,
/// Escape abandons". The editor is drawn *inside* its card's slot (`CardFaceView.isRenaming`), so
/// on this board a surface the filter doesn't reach means precisely a slot the filter doesn't
/// take away: filtering the card out would unmount the field mid-keystroke and silently drop what
/// the user had typed, which is the dirty-buffer courtesy read backwards. The exemption lasts
/// exactly as long as the editor commit or Escape retires it, the predicate applies again in
/// the same pass, and a card that no longer matches animates out then (which is also the whole of
/// "rename deliberately gets no carve-out": the *query* still stands throughout).
///
/// It cannot arrive by typing, because focusing the search field is focus loss and commits the
/// rename first; the case it serves is a foreign edit that stops the card matching while the user
/// is renaming it.
private var renderedCards: [Card] {
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
let filter = store.searchFilter
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) }
Self.rendered(
lane.cards,
hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootURL),
filter: store.searchFilter,
renaming: store.transient.renameEditor?.targetID
)
}
/// `renderedCards` as a pure function of its four inputs see there for every rule it applies.
/// Split out only so the rules can be pinned without a view (`SearchFilterTests`); the lane's
/// masonry, its count badge and its drop zones all read the property, which reads this.
nonisolated static func rendered(
_ cards: [Card],
hiddenByDrag hidden: Set<ItemID>,
filter: SearchFilter,
renaming: ItemID?
) -> [Card] {
cards.filter { card in
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
return filter.matches(card) || card.id == renaming
}
}
// MARK: - Selection
+10 -7
View File
@@ -97,13 +97,16 @@ extension View {
/// 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.
/// **A leaving card stays input-reachable for its out-transition** (04-interactions.md § Search,
/// settled): "marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card
/// mid-departure is briefly reachable while already out of the selection accepted: it is
/// literally on screen for that span, and closing the window would teach three input sites a
/// predicate the layout already applied". That is this modifier's construction rather than a rule
/// it implements: `onDisappear` fires when SwiftUI really removes the view at the end of the
/// card transition `Motion.contentReflow` is timing not when the query changed, so the
/// registration outlives the filter by exactly the length of the animation and not a frame more.
/// The card has already left the selection by then (`TransientBoardState.constrainToSearch(in:)`
/// runs at the keystroke), which is what makes the window visible rather than phantom.
@MainActor
func marqueeTarget(
_ id: ItemID,