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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user