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
150 lines
8.5 KiB
Swift
150 lines
8.5 KiB
Swift
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)
|
|
}
|
|
}
|