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:
2026-07-28 00:21:13 -04:00
parent 6c490ec71c
commit 2fc4020a2b
9 changed files with 1111 additions and 17 deletions
+149
View File
@@ -0,0 +1,149 @@
import CoreGraphics
import Foundation
/// The sole-selection attachment carousel's rules, as pure functions of the selection and the card
/// (`CardCarouselTests`) 03-board-ui.md § Card face:
///
/// > Attachments: **the sole selected card** shows the paged media carousel when it has attachments
/// > Single selection only: multi-selections and unselected cards stay compact, and the expansion
/// > animates under the selection-keyed transaction.
///
/// A rule surface rather than a method on the view, for the reason every rule in this codebase that
/// can be one is: the branches are lines of test rather than UI states to drive, and the animation
/// key and the render condition then read the *same* answer instead of two hand-kept-in-sync
/// conditions.
///
/// ### Two answers, deliberately, and why the key is the narrower one
///
/// `soleSelection(_:)` is **the animation key** 03 § Motion's "animated transactions are keyed
/// narrowly on the sole-selected card (carousel expansion) never on broad state like the
/// selection set". `expanded(_:marqueeActive:)` is **what renders**, which is the key with the
/// marquee's suppression on top (below).
///
/// Keeping the marquee out of the *key* is not an oversight it is the second half of the same
/// bullet. A band's selection churn sits in 03's animation-free-by-construction list, so a carousel
/// collapsing because a band began, and one expanding because a band ended naming exactly one card,
/// both change `expanded` without changing the key: no transaction, nothing eases, the face simply
/// is what it is. The only thing that ever animates is the sole selection genuinely becoming a
/// different card.
///
/// ### The marquee suppression, filed for design ratification
///
/// **A band in flight suppresses the carousel entirely** an implementation ruling, awaiting the
/// design's word. 03 says the carousel belongs to "the sole selected card" and says nothing about
/// the rubber band, which sweeps *through* sole selections on its way to a set: drag a band across a
/// row of single cards and every one of them is, for one sample, the entire selection. Expanding and
/// collapsing a carousel per sample would be the board flickering under the cursor, and 03 § Motion
/// already rules that the band is input echo rather than settled state ("the marquee rectangle
/// animating input echo would be lag"). The reading taken here is that a band *names a set in
/// progress, not a settled selection*, and that only a settled selection expands anything. The
/// moment the band ends, whatever it left behind is settled and the ordinary rule applies.
enum CardCarousel {
// MARK: - Who expands
/// The sole-selected **live** card, or `nil` the narrow animation key.
///
/// Three refusals, one line each:
///
/// - **A trashed selection expands nothing.** 03 puts the carousel on the card face, and a
/// tombstoned card has no face it is one row in the trash column (03 § Trash).
/// - **A multi-selection expands nothing** "multi-selections and unselected cards stay
/// compact", which is also why `count == 1` and not `count >= 1`.
/// - An empty selection expands nothing, which is the same clause.
///
/// **Kind falls out of identity rather than being checked here.** A selection is a set of ids
/// and nothing else (`ItemReferenceSet`), so its kind is always re-derived from the snapshot
/// and the one caller re-derives it in the cheapest way there is: a card face compares this
/// answer against *its own id* (`expands(_:expanded:)`), so a sole-selected **lane** returns
/// that lane's id here and matches no face on the board. One walk of the snapshot per rendered
/// card, to reach the same place, is the thing not done.
static func soleSelection(_ selection: ItemReferenceSet) -> ItemID? {
guard selection.liveness == .live, selection.ids.count == 1 else { return nil }
return selection.ids.first
}
/// The card whose carousel is actually **drawn**: the sole selection, suppressed for as long as
/// a rubber band is in flight the ruling filed above.
static func expanded(_ selection: ItemReferenceSet, marqueeActive: Bool) -> ItemID? {
marqueeActive ? nil : soleSelection(selection)
}
/// Whether *this* card draws the carousel: it is the whole selection, and it has files. A card
/// with no attachments stays compact however it is selected the face keeps its quiet paperclip
/// chip and nothing else changes (03 § Card face).
static func expands(_ card: Card, expanded: ItemID?) -> Bool {
guard let expanded, expanded == card.id else { return false }
return !card.attachments.isEmpty
}
// MARK: - What it pages through
/// One page: one attachment, named and located.
///
/// The name is the identity because a folder cannot hold two files by one name and because it
/// is what `Card.attachments` carries, so the page list and the snapshot cannot drift.
struct Page: Identifiable, Equatable, Sendable {
let name: String
let url: URL
var id: String { name }
}
/// The carousel's pages **one per attachment, in the loaded order and no other**.
///
/// That order is Finder's (`localizedStandardCompare`), settled in 01-storage-format.md
/// § Attachments and applied once, in the loader's `attachmentNames(in:)`: "one shared
/// enumeration between loader and Writer, so the carousel and the sidebar can never disagree".
/// Nothing here sorts, filters, or de-duplicates re-deciding any of that would be the second
/// answer that rule exists to prevent.
static func pages(of card: Card, boardRoot: URL, laneID: ItemID) -> [Page] {
let folder = attachmentsFolder(boardRoot: boardRoot, laneID: laneID, cardID: card.id)
return card.attachments.map { Page(name: $0, url: folder.appendingPathComponent($0)) }
}
/// `<root>/<lane>/<card>/attachments/` the fractal path 01-storage-format.md § Fractal layout
/// fixes, spelled through `BoardWriter.attachmentsFolderName` so the folder's one name lives in
/// one place.
static func attachmentsFolder(boardRoot: URL, laneID: ItemID, cardID: ItemID) -> URL {
boardRoot
.appendingPathComponent(laneID.rawValue, isDirectory: true)
.appendingPathComponent(cardID.rawValue, isDirectory: true)
.appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
}
// MARK: - The scroll wheel's direction
/// Which way one discrete scroll-wheel tick pages: `+1` forward, `-1` back, `0` for a tick that
/// carried nothing.
///
/// 03 § Card face lists three paging inputs "trackpad pan / dot click / scroll wheel" and
/// the first two are the stock paging behaviour's and a button's. The wheel is the one that
/// needs a rule, because a discrete tick is worth a handful of points and a paging scroll view
/// snaps a handful of points straight back to the page it started on: left alone, a wheel does
/// nothing at all here. `AttachmentCarousel` reads the tick and moves a whole page.
///
/// **The sign is AppKit's, not a guess.** A scroll view advances its document by *subtracting*
/// the scrolling delta from the visible origin, so a negative delta moves the content forward
/// and a positive one moves it back which is why this reads the sign rather than the
/// direction-inversion flag: `scrollingDelta` already has natural scrolling folded into it, and
/// undoing that would invert the setting rather than honour it.
///
/// A horizontal tick (a tilt wheel, or with a plain one) wins over a vertical one when both
/// are present: it names this carousel's own axis.
static func wheelStep(deltaX: CGFloat, deltaY: CGFloat) -> Int {
let delta = deltaX != 0 ? deltaX : deltaY
guard delta != 0 else { return 0 }
return delta < 0 ? 1 : -1
}
/// The page a tick lands on: the current index moved by `step` and **clamped**, never wrapped.
///
/// Clamped because a carousel is a short flat list rather than a loop wrapping from the last
/// attachment back to the first would make "how many are there" unanswerable by paging, and the
/// dots below already answer it at a glance.
static func page(from index: Int, step: Int, count: Int) -> Int {
guard count > 0 else { return 0 }
return min(max(0, index + step), count - 1)
}
}