Build the sole-selection attachment carousel
The card face's in-place expansion (03-board-ui.md § Card face): a card with attachments, as the sole selection, grows a paged media band below its title; every other state stays compact behind the quiet paperclip. - CardCarousel owns the pure rules: the sole-selection predicate decides by identity (a sole-selected lane matches no face, no snapshot walk), and expansion is suppressed outside the animation key while a rubber band is active — a band names a set in progress, so carousels neither flicker nor animate under it. - QuickLook thumbnails generate off-main into a per-window cache keyed to survive reselection, with the Finder-icon fallback while loading and for non-previewable types; pages ride the platform paging behavior, dots (glass underlay, solid under Reduce Transparency) click to page, and a local wheel monitor turns a discrete tick into one clamped page — precise trackpad pans fall through untouched. - The expansion animates under Motion's new carouselExpansion transaction keyed narrowly on the sole-selected card; Reduce Motion goes instant. Drop-slot math and the marquee read the expanded height for free — both re-register on every size change. 928 unit tests (24 new). m5-interactions complete. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -24,11 +24,11 @@ import SwiftUI
|
||||
/// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus'
|
||||
/// Lane row, in its order, complete as of m5.
|
||||
///
|
||||
/// ### What is still a later card's
|
||||
/// ### The card face
|
||||
///
|
||||
/// The search-aware filtering behind the count belongs to a later milestone. The card face is real
|
||||
/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the
|
||||
/// sole-selected card's attachment carousel.
|
||||
/// `CardFaceView`, complete as of m5: the search filter narrows what the masonry lays out and what
|
||||
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and the sole-selected
|
||||
/// card expands its attachment carousel in place (`CardCarousel`).
|
||||
struct LaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -454,7 +454,8 @@ struct LaneView: View {
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
registry: marquee.registry,
|
||||
laneID: lane.id,
|
||||
marquee: marquee,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
)
|
||||
@@ -724,21 +725,35 @@ private enum LaneSlot: Identifiable {
|
||||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// ### Room for the carousel
|
||||
/// ### The attachment carousel
|
||||
///
|
||||
/// The face is a top-aligned `VStack` and its two decorations — the accent stripe and the selection
|
||||
/// stroke — are shapes in overlays, so both stretch to whatever height the content takes. That is
|
||||
/// what lets m5's carousel expand *inside* this card without any of it being re-derived: the
|
||||
/// masonry already isolates column heights, so a taller card pushes only the cards below it in its
|
||||
/// own column.
|
||||
/// what lets the carousel expand *inside* this card without any of it being re-derived: the masonry
|
||||
/// already isolates column heights, so a taller card pushes only the cards below it in its own
|
||||
/// column, and every consumer of a card's size reads a **live registered frame** rather than a
|
||||
/// nominal one — the drop model's resting grid (`onGeometryChange` into `LaneDropRegistry`) and the
|
||||
/// rubber band's sweep universe (`marqueeTarget`) both re-register the moment the card grows, so
|
||||
/// neither needed a word about the expansion.
|
||||
///
|
||||
/// Who expands and when is `CardCarousel`'s, and it is the whole of what this view decides here:
|
||||
/// `soleSelectedCardID` is the narrow animation key (03-board-ui.md § Motion), `expandedCardID` is
|
||||
/// the same answer with the rubber band's suppression on top, and the difference between them is
|
||||
/// why a band never animates anything.
|
||||
private struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame
|
||||
/// here and takes it out again when it leaves — see `View.marqueeTarget`.
|
||||
let registry: MarqueeTargetRegistry
|
||||
/// The lane this face is drawn in — the middle component of the card's folder path, which is
|
||||
/// what the carousel needs to reach `attachments/`. Passed rather than looked up: the lane
|
||||
/// rendering this face already knows, and a walk of the snapshot per card face to re-learn it
|
||||
/// would be the board's own layout asking the board where its cards are.
|
||||
let laneID: ItemID
|
||||
|
||||
/// The strip's rubber band — the registry this face registers its drawn frame into, and the
|
||||
/// session whose in-flight band suppresses the carousel (`CardCarousel.expanded`).
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
@@ -749,6 +764,10 @@ private struct CardFaceView: View {
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// Reduce Motion, for the carousel's expansion and its page slides (10-accessibility.md). Read
|
||||
/// from the environment and handed to `Motion`, which owns what "reduced" means.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
private let cornerRadius: CGFloat = 8
|
||||
@@ -760,11 +779,16 @@ private struct CardFaceView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
titleRow
|
||||
// m5-carousel: the sole selected card's paged attachment carousel expands here — below
|
||||
// the title, inside this same plate, keyed on the selection transaction
|
||||
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
|
||||
// view's `isSelected`; nothing above it changes.
|
||||
carousel
|
||||
}
|
||||
// **The narrow transaction** (03-board-ui.md § Motion: "animated transactions are keyed
|
||||
// narrowly — on the sole-selected card (carousel expansion) … never on broad state like the
|
||||
// selection set"). The key is the sole selection and nothing else: a multi-select's churn
|
||||
// never changes it, so multi-select churn never animates — the same bullet's other half,
|
||||
// held by construction rather than by suppression.
|
||||
//
|
||||
// The band is deliberately *not* in the key, only in what renders — see `CardCarousel`.
|
||||
.animation(Motion.carouselExpansion(reduced: reduceMotion), value: soleSelectedCardID)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
@@ -819,7 +843,7 @@ private struct CardFaceView: View {
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: registry)
|
||||
.marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
@@ -1073,6 +1097,37 @@ private struct CardFaceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The attachment carousel
|
||||
|
||||
/// The paged carousel, when this card is the entire selection and has files to page through
|
||||
/// (03-board-ui.md § Card face) — below the title, inside this same plate, adding height and
|
||||
/// changing nothing above it.
|
||||
///
|
||||
/// **An untitled card gets it identically**: the placeholder is what sits above, and 03 makes
|
||||
/// titles optional at every level without qualifying anything that reads them.
|
||||
@ViewBuilder
|
||||
private var carousel: some View {
|
||||
if CardCarousel.expands(card, expanded: expandedCardID) {
|
||||
AttachmentCarousel(
|
||||
pages: CardCarousel.pages(of: card, boardRoot: store.rootURL, laneID: laneID)
|
||||
)
|
||||
.transition(Motion.carouselTransition)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The animation key** — the sole-selected card, band or no band. See `CardCarousel`: keeping
|
||||
/// the marquee out of the key is what makes a band's arrival and departure change the face
|
||||
/// without easing anything, which is 03 § Motion's animation-free-by-construction rule for the
|
||||
/// marquee applied to the one surface its churn could otherwise animate.
|
||||
private var soleSelectedCardID: ItemID? {
|
||||
CardCarousel.soleSelection(store.selection)
|
||||
}
|
||||
|
||||
/// What actually expands: the key, suppressed for as long as a rubber band is in flight.
|
||||
private var expandedCardID: ItemID? {
|
||||
CardCarousel.expanded(store.selection, marqueeActive: marquee.session.isActive)
|
||||
}
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
|
||||
Reference in New Issue
Block a user