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:
@@ -0,0 +1,293 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AttachmentCarousel
|
||||
|
||||
/// The sole-selected card's paged attachment carousel, drawn inside the card face's own plate
|
||||
/// (03-board-ui.md § Card face):
|
||||
///
|
||||
/// > QuickLook thumbnails for anything previewable, Finder icon fallback, page dots on a glass
|
||||
/// > underlay, paged by trackpad pan / dot click / scroll wheel.
|
||||
///
|
||||
/// *Which* card draws it, and when, is `CardCarousel`'s — this view is handed a page list and knows
|
||||
/// nothing about selection. That split is what keeps the rule testable and the view free of it.
|
||||
///
|
||||
/// ### Display-only, this milestone
|
||||
///
|
||||
/// Nothing here opens a file, reveals it, or drags one out. Those belong to the card window's
|
||||
/// attachment section (05-card-window.md; m6-card-window below), which is also where
|
||||
/// 10-accessibility.md puts the *accessible* attachment surface — "the sole-selection **attachment
|
||||
/// carousel** … is decorative too — page dots and paging included, nothing focusable", the count
|
||||
/// already riding on the face's own element. Hence the whole thing is hidden from the accessibility
|
||||
/// tree, and every gesture it does not itself use falls through to the face beneath: a click still
|
||||
/// selects, a double click still opens the card window, a right click still raises the card's
|
||||
/// context menu.
|
||||
///
|
||||
// m6-card-window: opening, revealing and dragging an attachment out are the card window's sidebar
|
||||
// section, whose rows carry their own context menu (11-command-nexus.md ▸ Context menus). When that
|
||||
// lands, this view stays display-only — two surfaces, one of them keyboard-native, is the design's
|
||||
// own division of labour, not a gap here.
|
||||
struct AttachmentCarousel: View {
|
||||
|
||||
let pages: [CardCarousel.Page]
|
||||
|
||||
/// 10-accessibility.md ▸ Reduce Transparency: "glass underlays (carousel page dots) go solid".
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
|
||||
/// Reduce Motion, for the page slide a dot click and a wheel tick perform (10-accessibility.md).
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// The page currently under the viewport, as the scroll view reports it and as the dots and the
|
||||
/// wheel set it. `nil` until the first report, which is the first page.
|
||||
@State private var page: String?
|
||||
|
||||
/// The carousel's drawn width, which is every page's width — paging is only exact when a page is
|
||||
/// the viewport. Measured rather than derived: the face's content width is a masonry column
|
||||
/// minus the plate's padding, and re-deriving that here would be a second answer.
|
||||
@State private var width: CGFloat = 0
|
||||
|
||||
/// One wheel monitor's worth of storage — see `ScrollWheelPaging`.
|
||||
@State private var wheel = WheelMonitorBox()
|
||||
|
||||
/// The page area's height. Fixed rather than per-image, because a carousel whose height followed
|
||||
/// its content would resize the card on every page — and scaled with the text metric, since
|
||||
/// 10-accessibility.md commits the whole face to "relative text styles everywhere … layout
|
||||
/// survives the largest system text sizes".
|
||||
@ScaledMetric(relativeTo: .body) private var pageHeight: CGFloat = 116
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
pageStrip
|
||||
.frame(height: pageHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
.overlay(alignment: .bottom) { pageDots }
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 }
|
||||
.modifier(ScrollWheelPaging(box: wheel) { step(by: $0) })
|
||||
// Decorative, whole and entire (10-accessibility.md): the card is "one flattened
|
||||
// element" whose value already carries the attachment count, and nothing in here is a
|
||||
// second place to land.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
// MARK: - The pages
|
||||
|
||||
/// Horizontal paged scrolling — the stock behaviour, which is what a trackpad pan rides.
|
||||
///
|
||||
/// `LazyHStack` so a card with two dozen attachments generates two dozen thumbnails only if the
|
||||
/// user actually pages to them; `scrollTargetLayout` + `.paging` so a pan settles on a page
|
||||
/// rather than between two.
|
||||
private var pageStrip: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 0) {
|
||||
ForEach(pages) { page in
|
||||
AttachmentPage(page: page, width: width, height: pageHeight)
|
||||
.frame(width: width, height: pageHeight)
|
||||
.id(page.id)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollTargetBehavior(.paging)
|
||||
.scrollIndicators(.never)
|
||||
.scrollPosition(id: $page)
|
||||
}
|
||||
|
||||
// MARK: - The page dots
|
||||
|
||||
/// The dots, on their glass underlay — 03-board-ui.md § Card face names both.
|
||||
///
|
||||
/// **A single page shows none.** A dot row that can only ever read "1 of 1" is furniture over a
|
||||
/// picture; the row exists to say how many there are and where you are, and with one attachment
|
||||
/// there is nothing to say.
|
||||
@ViewBuilder
|
||||
private var pageDots: some View {
|
||||
if pages.count > 1 {
|
||||
underlaid {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(pages) { page in
|
||||
dot(for: page)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
|
||||
/// One dot, with a hit area much larger than itself: the dot is a 5-point mark and a 5-point
|
||||
/// click target would be a dare.
|
||||
private func dot(for target: CardCarousel.Page) -> some View {
|
||||
Circle()
|
||||
.fill(target.id == currentID ? AnyShapeStyle(.primary) : AnyShapeStyle(.tertiary))
|
||||
.frame(width: 5, height: 5)
|
||||
.frame(width: 14, height: 14)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
// The structural voice — a page arriving is a positional change, and 03 § Motion
|
||||
// gives every one of those the snappy spring.
|
||||
withAnimation(Motion.structural(reduced: reduceMotion)) { page = target.id }
|
||||
}
|
||||
}
|
||||
|
||||
/// The dots' backing: macOS 26 glass, or a solid capsule under Reduce Transparency
|
||||
/// (10-accessibility.md ▸ Reduce Transparency, verbatim: "glass underlays (carousel page dots)
|
||||
/// go solid"). Solid rather than merely opaque-ish — the accommodation asks for no blur at all,
|
||||
/// and a translucent fallback would be the same request half-honoured.
|
||||
@ViewBuilder
|
||||
private func underlaid<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||
if reduceTransparency {
|
||||
content().background(Capsule().fill(.background))
|
||||
} else {
|
||||
content().glassEffect(.regular, in: .capsule)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Paging
|
||||
|
||||
/// The page the dots highlight: what the scroll view last reported, or the first page before it
|
||||
/// has reported anything.
|
||||
private var currentID: String? {
|
||||
page ?? pages.first?.id
|
||||
}
|
||||
|
||||
/// Moves `step` pages from wherever the carousel is — the scroll wheel's landing, clamped by
|
||||
/// `CardCarousel.page(from:step:count:)` rather than wrapped.
|
||||
private func step(by step: Int) {
|
||||
guard let currentID, let index = pages.firstIndex(where: { $0.id == currentID }) else { return }
|
||||
let target = CardCarousel.page(from: index, step: step, count: pages.count)
|
||||
guard target != index else { return }
|
||||
withAnimation(Motion.structural(reduced: reduceMotion)) { page = pages[target].id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - One page
|
||||
|
||||
/// One attachment: its QuickLook thumbnail once there is one, its Finder icon until then and
|
||||
/// instead of one for anything QuickLook declines (03-board-ui.md § Card face).
|
||||
///
|
||||
/// The two states are deliberately not a crossfade or a spinner. A thumbnail lands in a few
|
||||
/// milliseconds from QuickLook's own cache for anything the user has seen in Finder, and a page that
|
||||
/// animated every arrival would flicker down a lane the arrow keys are walking.
|
||||
private struct AttachmentPage: View {
|
||||
|
||||
let page: CardCarousel.Page
|
||||
let width: CGFloat
|
||||
let height: CGFloat
|
||||
|
||||
/// The board window's thumbnail memory, read through the environment rather than threaded down
|
||||
/// the strip: nothing between `BoardView` and here has any use for it, and a parameter passed
|
||||
/// through four views to reach a leaf is four places to keep in step.
|
||||
@Environment(AttachmentThumbnailCache.self) private var thumbnails
|
||||
|
||||
/// The screen's backing scale — what the thumbnail is requested at, and what it is drawn at, so
|
||||
/// a Retina page is a Retina image rather than a doubled one.
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.background.tertiary)
|
||||
// Keyed on the slot, so a card that changes width regenerates and a reselection of the
|
||||
// same card at the same width does not — the cache's whole purpose, expressed as the
|
||||
// task's identity rather than as a guard inside it.
|
||||
.task(id: slot) {
|
||||
guard width > 0 else { return }
|
||||
await thumbnails.load(slot, url: page.url, height: height, scale: displayScale)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let image = thumbnails.thumbnail(for: slot) {
|
||||
Image(decorative: image, scale: displayScale)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
} else {
|
||||
// The icon is drawn at icon size rather than stretched to the page: an upscaled 32-point
|
||||
// icon is a blurry rectangle, and the fallback should read as "a file" rather than as a
|
||||
// failed picture.
|
||||
Image(nsImage: thumbnails.icon(forFileAt: page.url))
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
|
||||
private var slot: AttachmentThumbnailKey.Slot {
|
||||
AttachmentThumbnailKey.Slot(path: page.url.path, width: width)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The scroll wheel
|
||||
|
||||
/// Storage for the one local event monitor `ScrollWheelPaging` installs — a `@MainActor` class so
|
||||
/// it is `Sendable`, and a class so `@State` can hold a handle SwiftUI must not copy.
|
||||
@MainActor
|
||||
private final class WheelMonitorBox {
|
||||
var monitor: Any?
|
||||
}
|
||||
|
||||
/// Pages the carousel on a **discrete scroll-wheel tick** — the third of 03-board-ui.md § Card
|
||||
/// face's three paging inputs, and the only one the stock paging behaviour does not already serve
|
||||
/// (see `CardCarousel.wheelStep(deltaX:deltaY:)`).
|
||||
///
|
||||
/// ### Why an `NSEvent` monitor
|
||||
///
|
||||
/// SwiftUI has no scroll-wheel modifier, and the view that *would* receive the event is the paging
|
||||
/// scroll view itself — anything layered over it to intercept would also swallow the pan this is
|
||||
/// meant to leave alone. A local monitor sees the event before the scroll view does and can decline
|
||||
/// it, which is exactly the shape the problem has.
|
||||
///
|
||||
/// ### What it declines, which is nearly everything
|
||||
///
|
||||
/// - **It exists only while the pointer is over this carousel.** At most one card on a board is the
|
||||
/// sole selection, so there is at most one of these in the app, and only while hovered.
|
||||
/// - **Trackpad and Magic Mouse pans fall straight through**, gated on
|
||||
/// `hasPreciseScrollingDeltas` and on the event carrying no phase: a pan is a continuous gesture
|
||||
/// the paging behaviour already handles correctly, and taking it away would replace a smooth drag
|
||||
/// with a jump.
|
||||
/// - **A tick that resolves to no direction falls through too**, rather than being eaten for
|
||||
/// nothing.
|
||||
private struct ScrollWheelPaging: ViewModifier {
|
||||
|
||||
let box: WheelMonitorBox
|
||||
let step: (Int) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onHover { hovering in
|
||||
if hovering { install() } else { remove() }
|
||||
}
|
||||
// The carousel collapses the moment its card stops being the sole selection, and a
|
||||
// collapse while hovered would otherwise leave the monitor behind.
|
||||
.onDisappear(perform: remove)
|
||||
}
|
||||
|
||||
private func install() {
|
||||
guard box.monitor == nil else { return }
|
||||
box.monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
||||
guard !event.hasPreciseScrollingDeltas,
|
||||
event.phase.isEmpty,
|
||||
event.momentumPhase.isEmpty
|
||||
else { return event }
|
||||
|
||||
let direction = CardCarousel.wheelStep(
|
||||
deltaX: event.scrollingDeltaX,
|
||||
deltaY: event.scrollingDeltaY
|
||||
)
|
||||
guard direction != 0 else { return event }
|
||||
// A local monitor is delivered on the main thread by contract; the event itself never
|
||||
// crosses the hop, only the direction it resolved to.
|
||||
MainActor.assumeIsolated { step(direction) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func remove() {
|
||||
guard let monitor = box.monitor else { return }
|
||||
NSEvent.removeMonitor(monitor)
|
||||
box.monitor = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user