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,181 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user