Remove the face carousel — one card presentation
03's resettlement reverses the pathfinder carry-over: the selection-keyed dual presentation proved undesirable, so a card has one presentation — selection changes styling, never geometry, and the masonry never reflows on click. Deleted the carousel view (page dots, glass underlay, scroll-tick monitor), the QuickLook thumbnail cache (sole consumer), the pure paging/suppression rules, and the sole-selected animation key — Motion now keys transactions on the search query and the drop proposal only. The attachment chip stays as the face's whole attachment story; viewing media is the card window's job. No carousel state had leaked beyond the view layer. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -1,293 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import QuickLookThumbnailing
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AttachmentThumbnailKey
|
||||
|
||||
/// What a generated thumbnail is filed under: **which file, at which drawn size, as of which
|
||||
/// bytes** (03-board-ui.md § Card face — "QuickLook thumbnails for anything previewable").
|
||||
///
|
||||
/// The third component is what makes the cache honest rather than merely fast. A board is a live
|
||||
/// view over folders anyone may write to (02-architecture.md), so a cache keyed on the path alone
|
||||
/// would happily show a picture of a file that has since been replaced — the one failure mode this
|
||||
/// app treats as a bug rather than a staleness. Modification date *and* size, because either alone
|
||||
/// misses a case: a same-second rewrite keeps the date, and an edit that preserves the length keeps
|
||||
/// the size.
|
||||
///
|
||||
/// **Missing values are a legitimate key, not a refusal.** A file that cannot be stat'd — it went
|
||||
/// away between the listing and the render, a volume dropped — keys as `(nil, nil)` and simply
|
||||
/// misses on the next attempt, which is the ordinary "no thumbnail, show the icon" path rather than
|
||||
/// a second error surface.
|
||||
struct AttachmentThumbnailKey: Hashable, Sendable {
|
||||
|
||||
/// The half a *view* can name without touching the disk — the path and the width it is drawn at.
|
||||
///
|
||||
/// The cache is looked up by this during a render and completed by the full key in a task,
|
||||
/// which is the whole reason it is a type: a render must never `stat`, and a stale-check must
|
||||
/// never be skipped. The cache holds one `Slot → Key` entry per file it has ever resolved, so
|
||||
/// the lookup on screen stays two dictionary reads.
|
||||
struct Slot: Hashable, Sendable {
|
||||
let path: String
|
||||
let width: Int
|
||||
|
||||
/// Widths **bucket to whole points**: a card face is laid out by a `Layout` and its content
|
||||
/// width can land on a fraction, and a thumbnail regenerated because a card grew by a third
|
||||
/// of a point would be a cache that never hits.
|
||||
init(path: String, width: CGFloat) {
|
||||
self.path = path
|
||||
self.width = max(1, Int(width.rounded()))
|
||||
}
|
||||
}
|
||||
|
||||
let slot: Slot
|
||||
let modified: Date?
|
||||
let size: Int64?
|
||||
}
|
||||
|
||||
// MARK: - AttachmentThumbnailCache
|
||||
|
||||
/// The board window's thumbnail memory — **one per board window**, held by `BoardView` and read
|
||||
/// through the environment by the faces beneath it.
|
||||
///
|
||||
/// ### Why the window and not the app, and not the card
|
||||
///
|
||||
/// Per *card* would defeat the point: 03-board-ui.md § Card face expands the carousel on selection,
|
||||
/// so a user walking a lane with the arrow keys reselects cards constantly, and a cache that died
|
||||
/// with the view would regenerate every thumbnail on every visit. Per *app* would outlive the thing
|
||||
/// it is caching — a board window closing is the natural moment to forget its files, and a
|
||||
/// process-wide dictionary keyed by path would keep a closed board's images alive for the session.
|
||||
/// The window is the scope where "the files I am looking at" is true.
|
||||
///
|
||||
/// ### What it does not do
|
||||
///
|
||||
/// It never watches, invalidates or evicts on change: the key carries the file's stamp, so a
|
||||
/// rewritten file simply keys differently and the stale entry ages out under the cap below. There is
|
||||
/// deliberately no invalidation path to keep honest — the same posture `Card.attachments` takes
|
||||
/// ("freshness is the watcher's, by construction").
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AttachmentThumbnailCache {
|
||||
|
||||
/// How many generated thumbnails one window keeps. A cap rather than unbounded growth because a
|
||||
/// board can hold thousands of attachments and a decoded image is not small; a plain
|
||||
/// insertion-ordered drop rather than a recency policy because the access pattern this serves is
|
||||
/// "the card I just selected, and the one before it".
|
||||
static let limit = 96
|
||||
|
||||
/// The resolved stamp for each drawn slot — the render-time half of the lookup.
|
||||
private var keys: [AttachmentThumbnailKey.Slot: AttachmentThumbnailKey] = [:]
|
||||
|
||||
private var images: [AttachmentThumbnailKey: CGImage] = [:]
|
||||
|
||||
/// Insertion order over `images`, for the cap.
|
||||
private var order: [AttachmentThumbnailKey] = []
|
||||
|
||||
/// Keys QuickLook declined — a plain-text file, an unknown type, an unreadable one. Remembered
|
||||
/// so a non-previewable attachment costs one generation attempt per version of itself rather
|
||||
/// than one per reselection; the face shows its Finder icon and stops asking.
|
||||
private var unpreviewable: Set<AttachmentThumbnailKey> = []
|
||||
|
||||
/// Finder icons, by path. **Deliberately outside observation** (`@ObservationIgnored`): this one
|
||||
/// is filled *during* a render, because an icon is the fallback a page draws while its thumbnail
|
||||
/// is still being made, and a tracked write there would invalidate the view that just read it.
|
||||
/// Nothing depends on an icon changing — `NSWorkspace` answers the same image for the same path
|
||||
/// until the app is relaunched.
|
||||
@ObservationIgnored private var icons: [String: NSImage] = [:]
|
||||
|
||||
// MARK: - Reading, during a render
|
||||
|
||||
/// This slot's thumbnail, or `nil` when there is not one *yet* — the two dictionary reads a
|
||||
/// render is allowed to do. A miss is not a failure; it is the icon fallback's cue.
|
||||
func thumbnail(for slot: AttachmentThumbnailKey.Slot) -> CGImage? {
|
||||
guard let key = keys[slot] else { return nil }
|
||||
return images[key]
|
||||
}
|
||||
|
||||
/// The file's Finder icon — "Finder icon fallback" (03-board-ui.md § Card face), shown while a
|
||||
/// thumbnail is being generated and kept for anything QuickLook cannot preview.
|
||||
///
|
||||
/// `icon(forFile:)` rather than an icon for the file's *type*, deliberately: it is what Finder
|
||||
/// itself shows, custom icons and application bundles included, and an attachment that looks
|
||||
/// different in Finder than on the card would be the app disagreeing with the substrate it is a
|
||||
/// view over.
|
||||
func icon(forFileAt url: URL) -> NSImage {
|
||||
if let cached = icons[url.path] { return cached }
|
||||
let icon = NSWorkspace.shared.icon(forFile: url.path)
|
||||
icons[url.path] = icon
|
||||
return icon
|
||||
}
|
||||
|
||||
// MARK: - Filling, off the render
|
||||
|
||||
/// Resolves this slot's key and generates its thumbnail if that key has none — the whole of the
|
||||
/// cache's write side, called from a page's `.task` and never from a body.
|
||||
///
|
||||
/// Both halves run **off the main actor** (`nonisolated`): the `stat` because a render is
|
||||
/// waiting on this task, and the generation because it is a round trip to a QuickLook extension
|
||||
/// in another process. Only `CGImage` crosses back, which is `Sendable`; the representation the
|
||||
/// generator hands out is not, and never leaves the function that made it.
|
||||
func load(_ slot: AttachmentThumbnailKey.Slot, url: URL, height: CGFloat, scale: CGFloat) async {
|
||||
let key = await Self.resolve(slot, url: url)
|
||||
keys[slot] = key
|
||||
guard images[key] == nil, !unpreviewable.contains(key) else { return }
|
||||
|
||||
let size = CGSize(width: CGFloat(slot.width), height: height)
|
||||
guard let image = await Self.generate(url: url, size: size, scale: scale) else {
|
||||
unpreviewable.insert(key)
|
||||
return
|
||||
}
|
||||
remember(image, for: key)
|
||||
}
|
||||
|
||||
private func remember(_ image: CGImage, for key: AttachmentThumbnailKey) {
|
||||
if images.updateValue(image, forKey: key) == nil {
|
||||
order.append(key)
|
||||
}
|
||||
while order.count > Self.limit {
|
||||
images.removeValue(forKey: order.removeFirst())
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func resolve(
|
||||
_ slot: AttachmentThumbnailKey.Slot,
|
||||
url: URL
|
||||
) async -> AttachmentThumbnailKey {
|
||||
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey])
|
||||
return AttachmentThumbnailKey(
|
||||
slot: slot,
|
||||
modified: values?.contentModificationDate,
|
||||
size: values?.fileSize.map(Int64.init)
|
||||
)
|
||||
}
|
||||
|
||||
/// One QuickLook request, at the page's own size. `.thumbnail` alone rather than
|
||||
/// `.all`: the icon representations QuickLook would fall back to are exactly what
|
||||
/// `icon(forFileAt:)` already draws, and taking them here would cache a decorated icon under a
|
||||
/// thumbnail's key and never try for a real one again. `iconMode` is off for the same reason —
|
||||
/// the page wants the picture, not a page-curled document.
|
||||
private nonisolated static func generate(url: URL, size: CGSize, scale: CGFloat) async -> CGImage? {
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: url,
|
||||
size: size,
|
||||
scale: scale,
|
||||
representationTypes: .thumbnail
|
||||
)
|
||||
request.iconMode = false
|
||||
guard let representation = try? await QLThumbnailGenerator.shared.generateBestRepresentation(for: request)
|
||||
else { return nil }
|
||||
return representation.cgImage
|
||||
}
|
||||
}
|
||||
@@ -94,14 +94,6 @@ struct BoardView: View {
|
||||
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
|
||||
@State private var marqueeTargets = MarqueeTargetRegistry()
|
||||
|
||||
/// This window's attachment thumbnails, for the sole-selected card's carousel (03-board-ui.md
|
||||
/// § Card face). `@State` for the sessions' reason — one per window, living exactly as long as
|
||||
/// the window, so a board that closes forgets the files it was showing (see
|
||||
/// `AttachmentThumbnailCache` on why the window and not the app). Handed down through the
|
||||
/// environment rather than threaded, since nothing between here and a card face has any use for
|
||||
/// it.
|
||||
@State private var thumbnails = AttachmentThumbnailCache()
|
||||
|
||||
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
|
||||
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
|
||||
/// included, and no global or lane-local space is that.
|
||||
@@ -157,7 +149,6 @@ struct BoardView: View {
|
||||
.onDrop(of: boardDropTypes, delegate: StripDropDelegate(context: dropContext))
|
||||
}
|
||||
.background(boardBackground)
|
||||
.environment(thumbnails)
|
||||
// The window-level fallback, *behind* the specific targets: a release over any in-window
|
||||
// region they don't cover commits the current proposal instead of leaking the session into a
|
||||
// cancel-snapback — the drop lands where the shadows show, which is what the shadows promise.
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,11 @@ import SwiftUI
|
||||
/// ### The card face
|
||||
///
|
||||
/// `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`).
|
||||
/// the badge counts, the deferred cut's dim rides the face (`cutTreatment`), and selection changes
|
||||
/// only the face's styling — never its geometry. **A card has one presentation** (resettled
|
||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): the masonry never reflows on
|
||||
/// a click, and viewing an attachment's media is the card window's job, not the face's
|
||||
/// (03-board-ui.md § Card face).
|
||||
struct LaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -457,7 +460,6 @@ struct LaneView: View {
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
laneID: lane.id,
|
||||
marquee: marquee,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
@@ -865,8 +867,7 @@ private enum CardFaceMetrics {
|
||||
static let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip — and between the title row and the
|
||||
/// carousel below it.
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
@@ -893,34 +894,23 @@ private enum CardFaceMetrics {
|
||||
/// 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).
|
||||
///
|
||||
/// ### The attachment carousel
|
||||
/// ### One presentation, selection styling only
|
||||
///
|
||||
/// 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 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.
|
||||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||||
/// face).
|
||||
private struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
|
||||
/// 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`).
|
||||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
@@ -932,10 +922,6 @@ 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.
|
||||
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
@@ -947,18 +933,7 @@ private struct CardFaceView: View {
|
||||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: CardFaceMetrics.rowSpacing) {
|
||||
titleRow
|
||||
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)
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
@@ -1258,7 +1233,7 @@ private struct CardFaceView: View {
|
||||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||||
/// content does, m5's carousel expansion included.
|
||||
/// content does — a title wrapping across its full four lines included.
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let color = Palette.color(for: card.background) {
|
||||
@@ -1270,37 +1245,6 @@ 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 {
|
||||
@@ -1354,8 +1298,8 @@ private struct CardFaceView: View {
|
||||
///
|
||||
/// A newly created card is always default-styled — `BoardWriter.createCard` writes `schema`, `title`
|
||||
/// and `order` and nothing else — so "the arriving card's chrome" is exactly: the level-default
|
||||
/// symbol, the standard secondary tint, no accent stripe, no selection stroke (the commit re-selects
|
||||
/// the *lane*), and no carousel (nothing is attached yet, and it is not the sole selection).
|
||||
/// symbol, the standard secondary tint, no accent stripe, and no selection stroke (the commit
|
||||
/// re-selects the *lane*).
|
||||
private struct NewCardStubView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -1459,8 +1403,8 @@ private struct NewCardStubView: View {
|
||||
///
|
||||
/// **`NewCardStubView.arrivingFace`'s precedent, applied to the drag**, and for the same reason: a
|
||||
/// static rendition at the same numbers (`CardFaceMetrics`) is what makes the echo's swap invisible
|
||||
/// rather than merely un-animated. It carries no gestures, no drop target, no geometry registration
|
||||
/// and no carousel — it stands in for exactly one round trip, and every surface that reads a card's
|
||||
/// rather than merely un-animated. It carries no gestures, no drop target, and no geometry
|
||||
/// registration — it stands in for exactly one round trip, and every surface that reads a card's
|
||||
/// drawn frame (the drop zones, the rubber band) is reading the *snapshot*'s cards, which this is
|
||||
/// not one of.
|
||||
///
|
||||
|
||||
@@ -90,9 +90,9 @@ struct MasonryPlacement: Equatable, Sendable {
|
||||
/// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column
|
||||
/// `i % columns`), and each column stacks its children top-aligned and independently — there is
|
||||
/// **no row alignment across columns**. With uniform card heights this renders exactly like a
|
||||
/// row-major grid, but when one card grows taller (the sole selected card's attachment carousel,
|
||||
/// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the
|
||||
/// neighbouring columns do not move.
|
||||
/// row-major grid, but when one card grows taller than its neighbours (a longer title wrapping
|
||||
/// across more lines, say) it only pushes the cards below it in its *own* column; the neighbouring
|
||||
/// columns do not move.
|
||||
///
|
||||
/// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single
|
||||
/// `ForEach` — reflowing cards across columns preserves view identity and animates as positional
|
||||
|
||||
Reference in New Issue
Block a user