From 2fc4020a2ba4662457ec628cd3205c298ddbf190 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 00:21:13 -0400 Subject: [PATCH] Build the sole-selection attachment carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/UI/Board/AttachmentCarouselView.swift | 293 +++++++++++++++ Kanban/UI/Board/AttachmentThumbnails.swift | 181 ++++++++++ Kanban/UI/Board/BoardView.swift | 9 + Kanban/UI/Board/CardCarousel.swift | 149 ++++++++ Kanban/UI/Board/LaneView.swift | 89 ++++- Kanban/UI/Motion.swift | 28 ++ KanbanTests/CardCarouselTests.swift | 356 +++++++++++++++++++ KanbanTests/MotionTests.swift | 21 ++ README.md | 2 + 9 files changed, 1111 insertions(+), 17 deletions(-) create mode 100644 Kanban/UI/Board/AttachmentCarouselView.swift create mode 100644 Kanban/UI/Board/AttachmentThumbnails.swift create mode 100644 Kanban/UI/Board/CardCarousel.swift create mode 100644 KanbanTests/CardCarouselTests.swift diff --git a/Kanban/UI/Board/AttachmentCarouselView.swift b/Kanban/UI/Board/AttachmentCarouselView.swift new file mode 100644 index 0000000..75868fa --- /dev/null +++ b/Kanban/UI/Board/AttachmentCarouselView.swift @@ -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(@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 + } +} diff --git a/Kanban/UI/Board/AttachmentThumbnails.swift b/Kanban/UI/Board/AttachmentThumbnails.swift new file mode 100644 index 0000000..4fd05a5 --- /dev/null +++ b/Kanban/UI/Board/AttachmentThumbnails.swift @@ -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 = [] + + /// 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 + } +} diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 4029302..c1b9a72 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -94,6 +94,14 @@ 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. @@ -149,6 +157,7 @@ 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. diff --git a/Kanban/UI/Board/CardCarousel.swift b/Kanban/UI/Board/CardCarousel.swift new file mode 100644 index 0000000..6b5cbd1 --- /dev/null +++ b/Kanban/UI/Board/CardCarousel.swift @@ -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)) } + } + + /// `///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) + } +} diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index c8676d3..767eee3 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -24,11 +24,11 @@ import SwiftUI /// the quick-style recents row, the Width stepper and Delete — 11-command-nexus.md ▸ Context menus' /// Lane row, in its order, complete as of m5. /// -/// ### What is still a later card's +/// ### The card face /// -/// The search-aware filtering behind the count belongs to a later milestone. The card face is real -/// (`CardFaceView`) and wears the deferred cut's dim (`cutTreatment`); what it still owes is the -/// sole-selected card's attachment carousel. +/// `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`). struct LaneView: View { let store: BoardStore @@ -454,7 +454,8 @@ struct LaneView: View { CardFaceView( store: store, card: card, - registry: marquee.registry, + laneID: lane.id, + marquee: marquee, drops: drops, openCard: openCard ) @@ -724,21 +725,35 @@ private enum LaneSlot: Identifiable { /// 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). /// -/// ### Room for the carousel +/// ### The attachment carousel /// /// 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 m5's 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. +/// 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. private struct CardFaceView: View { let store: BoardStore let card: Card - /// Where the rubber band looks up what it is sweeping. The face registers its own drawn frame - /// here and takes it out again when it leaves — see `View.marqueeTarget`. - let registry: MarqueeTargetRegistry + /// 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`). + let marquee: MarqueeControl /// The board window's drop machinery: this face registers its measured height into the geometry /// registry (the resting grid's input) and starts the card drag session from `.onDrag`. @@ -749,6 +764,10 @@ 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. private let cornerRadius: CGFloat = 8 @@ -760,11 +779,16 @@ private struct CardFaceView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { titleRow - // m5-carousel: the sole selected card's paged attachment carousel expands here — below - // the title, inside this same plate, keyed on the selection transaction - // (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this - // view's `isSelected`; nothing above it changes. + 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) .frame(maxWidth: .infinity, alignment: .leading) .padding(10) // Constant, whether or not a stripe paints: every card's text sits on the same grid, so @@ -819,7 +843,7 @@ private struct CardFaceView: View { drops.registry.update(height: height, for: card.id) } .onDisappear { drops.registry.removeHeight(card.id) } - .marqueeTarget(card.id, kind: .card, side: .live, in: registry) + .marqueeTarget(card.id, kind: .card, side: .live, in: marquee.registry) .contextMenu { cardMenu } .popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) { StyleEditorPopover(store: store, recents: appModel.styleRecents) @@ -1073,6 +1097,37 @@ 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 { diff --git a/Kanban/UI/Motion.swift b/Kanban/UI/Motion.swift index 843185d..f24b819 100644 --- a/Kanban/UI/Motion.swift +++ b/Kanban/UI/Motion.swift @@ -105,6 +105,20 @@ enum Motion { reduced ? nil : .snappy(duration: Duration.laneResize) } + /// **The sole-selected card's carousel expanding and collapsing** — 03-board-ui.md § Motion's + /// first named narrow key ("keyed narrowly — on the sole-selected card (carousel expansion)"). + /// + /// The structural voice, because that is what the change *is*: the card grows, and the cards + /// below it in its masonry column slide down to make room. Nothing about it is a content reflow + /// — the carousel's own arrival is a crossfade over the movement (`carouselAppearance`), and the + /// movement is positional. + /// + /// It is spelled here rather than aliased at the call site so the figure stays one decision: if + /// the expansion ever wants a duration of its own, this is the line that changes. + static func carouselExpansion(reduced: Bool) -> Animation? { + structural(reduced: reduced) + } + /// The content-reflow voice: search filtering and undo/redo restore, "deliberately paired so a /// restore reads like the search filter — leavers and arrivers run their transition, survivors /// reflow under one gentle spring". @@ -165,6 +179,20 @@ enum Motion { laneAppearance(reduced: reduced).transition } + /// **The attachment carousel arriving inside an already-drawn card** — the one appearance in the + /// app with no reduced variant, because it is already the reduced form. + /// + /// 03-board-ui.md fixes an appear/disappear *scale* for cards (~0.8) and lanes (~0.9) and for + /// nothing else, and inventing a third would be the per-site literal this type exists to + /// prevent. It is also unnecessary: the movement a carousel expansion performs is the card + /// growing, which `carouselExpansion` already animates — the carousel itself only has to arrive + /// over it, and 10-accessibility.md's word for that is crossfade. So a Reduce Motion user gets + /// the same fade with the growth gone instant, which is exactly what the per-voice rule asks + /// for, one accessor rather than two. + static var carouselAppearance: Appearance { .crossfade } + + static var carouselTransition: AnyTransition { carouselAppearance.transition } + // MARK: - The AppKit face /// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number diff --git a/KanbanTests/CardCarouselTests.swift b/KanbanTests/CardCarouselTests.swift new file mode 100644 index 0000000..48f7f40 --- /dev/null +++ b/KanbanTests/CardCarouselTests.swift @@ -0,0 +1,356 @@ +import Foundation +import Testing +@testable import Kanban + +/// The sole-selection attachment carousel's rules (03-board-ui.md § Card face, § Motion; +/// 10-accessibility.md ▸ Reduce Transparency). +/// +/// **What is testable here is the whole of what was written to be.** A thumbnail is a round trip to +/// a QuickLook extension in another process, a page slide is a scroll view under a pointer, and a +/// glass underlay is pixels — none of that is a unit test. What *is* one is every decision that +/// precedes them: who expands, when the rubber band suppresses it, what the pages are and in what +/// order, what a cached thumbnail is filed under, and which way a wheel tick moves. Those are pure +/// functions precisely so this file can exist. +/// +/// The board underneath is a real load off a real temp tree, `NewCardTargetTests`' posture and for +/// its reason: the ordering these assertions make claims about is the *loader's* +/// (`localizedStandardCompare`, 01-storage-format.md § Attachments), and a hand-built `Card` would +/// let this file agree with itself while disagreeing with the app. + +// MARK: - Fixtures + +/// `WriterFixture`, `Ident` and `Item` live in `WriterTestSupport.swift`. + +private let lane1 = ItemID(rawValue: Ident.lane1) +private let lane2 = ItemID(rawValue: Ident.lane2) +private let card1 = ItemID(rawValue: Ident.card1) +private let card2 = ItemID(rawValue: Ident.card2) +private let card3 = ItemID(rawValue: Ident.card3) + +/// A card with no `title` key at all — the untitled placeholder's card, which 03 gives the carousel +/// on exactly the same terms as any other. +private func untitled(order: String) -> String { + """ + --- + schema: 1 + order: \(order) + --- + Body without a title. + + """ +} + +/// One lane, three cards: one with attachments whose names sort by Finder's rule rather than by +/// ASCII, one with none, and one untitled but attached. +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Attached")) + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Bare")) + try fixture.item("\(Ident.lane1)/\(Ident.card3)", untitled(order: "3072")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + + // Finder order is `localizedStandardCompare`, so "shot2" precedes "shot10" — the one ordering a + // plain lexicographic sort gets wrong, which is why these are the names. + for name in ["shot10.png", "shot2.png", "notes.txt"] { + try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(name)", Data("x".utf8)) + } + try fixture.file("\(Ident.lane1)/\(Ident.card3)/attachments/only.pdf", Data("x".utf8)) + return fixture +} + +private func card(_ id: ItemID, in snapshot: BoardModel) throws -> Card { + try #require(snapshot.lanes.flatMap(\.cards).first { $0.id == id }) +} + +private func live(_ ids: ItemID...) -> ItemReferenceSet { + ItemReferenceSet(ids: Set(ids), liveness: .live) +} + +// MARK: - Who expands + +@MainActor +@Suite("CardCarousel ▸ the sole-selection rule") +struct CardCarouselSelectionTests { + + /// The positive case, stated on its own: one live card, and it is the key. + @Test("A sole live selection is the key") + func aSoleLiveSelectionIsTheKey() { + #expect(CardCarousel.soleSelection(live(card1)) == card1) + } + + /// "Multi-selections and unselected cards stay compact" — both halves, and the reason the + /// predicate reads `count == 1` rather than `count >= 1`. + @Test("Nothing selected, and more than one thing selected, expand nothing") + func multiAndEmptySelectionsExpandNothing() { + #expect(CardCarousel.soleSelection(.empty) == nil) + #expect(CardCarousel.soleSelection(live(card1, card2)) == nil) + #expect(CardCarousel.soleSelection(live(card1, card2, card3)) == nil) + } + + /// **Side.** The carousel is a *card face* surface, and a tombstoned card has no face — it is + /// one row in the trash column (03-board-ui.md § Trash). A sole trashed selection is therefore + /// not a smaller carousel; it is none. + @Test("A sole trashed selection expands nothing") + func aSoleTrashedSelectionExpandsNothing() { + #expect(CardCarousel.soleSelection(ItemReferenceSet(ids: [card1], liveness: .trashed)) == nil) + } + + /// **Kind, settled by identity rather than by a snapshot walk.** A sole-selected *lane* is a + /// perfectly good sole selection and this function says so — it returns the lane's id, which + /// then matches no card face on the board, so nothing expands. The claim under test is that the + /// two steps compose to the right answer, because that composition is what saved every rendered + /// card a walk of the snapshot. + @Test("A sole-selected lane matches no card face") + func aSoleSelectedLaneMatchesNoCardFace() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let attached = try card(card1, in: snapshot) + + let sole = CardCarousel.soleSelection(live(lane1)) + #expect(sole == lane1) + #expect(!CardCarousel.expands(attached, expanded: sole)) + } + + /// The face's own half of the rule: it is the whole selection **and** it has files. + @Test("A card expands only when it is the selection and has attachments") + func aCardExpandsOnlyWhenSelectedAndAttached() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let attached = try card(card1, in: snapshot) + let bare = try card(card2, in: snapshot) + + #expect(CardCarousel.expands(attached, expanded: card1)) + // "The compact face keeps its quiet paperclip chip unchanged" — a card with no files has + // nothing to page through, however it is selected. + #expect(!CardCarousel.expands(bare, expanded: card2)) + // Somebody else's selection. + #expect(!CardCarousel.expands(attached, expanded: card2)) + #expect(!CardCarousel.expands(attached, expanded: nil)) + } + + /// **An untitled card gets the carousel on identical terms** — 03 makes titles optional at every + /// level and qualifies nothing that reads them, so the placeholder sits above and the pages sit + /// below exactly as they would for a titled card. + @Test("An untitled card expands like any other") + func anUntitledCardExpandsLikeAnyOther() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let untitledCard = try card(card3, in: snapshot) + + #expect(untitledCard.title.value == nil) + #expect(CardCarousel.expands(untitledCard, expanded: card3)) + } +} + +// MARK: - The marquee suppression + +@MainActor +@Suite("CardCarousel ▸ the rubber band's suppression") +struct CardCarouselMarqueeTests { + + /// The ruling filed for ratification, as a pure function of the two things it reads: a band in + /// flight expands nothing, whatever the selection currently says. + @Test("A band in flight suppresses every expansion") + func aBandInFlightSuppressesEveryExpansion() { + #expect(CardCarousel.expanded(live(card1), marqueeActive: false) == card1) + #expect(CardCarousel.expanded(live(card1), marqueeActive: true) == nil) + #expect(CardCarousel.expanded(.empty, marqueeActive: true) == nil) + #expect(CardCarousel.expanded(live(card1, card2), marqueeActive: true) == nil) + } + + /// **The band never enters the animation key** (03-board-ui.md § Motion's + /// animation-free-by-construction list, applied to the one surface a band's churn could + /// otherwise animate). The key is the selection alone, so beginning and ending a band change + /// what renders without changing what the transaction is keyed on — no transaction, nothing + /// eases. This is the assertion that would fail if the two answers were ever collapsed into one. + @Test("Suppression changes what renders, never the animation key") + func suppressionNeverMovesTheAnimationKey() { + let selection = live(card1) + #expect(CardCarousel.soleSelection(selection) == card1) + #expect(CardCarousel.expanded(selection, marqueeActive: true) == nil) + // The key is untouched by the band by construction: it does not take the flag at all. + #expect(CardCarousel.soleSelection(selection) == card1) + } +} + +// MARK: - The pages + +@MainActor +@Suite("CardCarousel ▸ pages") +struct CardCarouselPageTests { + + /// One page per attachment, **in the order the loader produced** — Finder's, which is why + /// `shot2` precedes `shot10` and why nothing here sorts. + @Test("Pages are one per attachment, in the loaded Finder order") + func pagesFollowTheLoadedOrder() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let attached = try card(card1, in: snapshot) + + #expect(attached.attachments == ["notes.txt", "shot2.png", "shot10.png"]) + + let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1) + #expect(pages.count == attached.attachments.count) + #expect(pages.map(\.name) == attached.attachments) + #expect(pages.map(\.id) == attached.attachments) + } + + /// Each page points at the file the fractal layout puts it in — + /// `///attachments/` (01-storage-format.md § Fractal layout). + @Test("Each page points at the file under the card's attachments folder") + func eachPagePointsAtItsFile() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let attached = try card(card1, in: snapshot) + + let pages = CardCarousel.pages(of: attached, boardRoot: fixture.root, laneID: lane1) + let folder = CardCarousel.attachmentsFolder(boardRoot: fixture.root, laneID: lane1, cardID: card1) + #expect(folder.lastPathComponent == "attachments") + for page in pages { + #expect(page.url == folder.appendingPathComponent(page.name)) + #expect(FileManager.default.fileExists(atPath: page.url.path)) + } + } + + /// A card with no files pages through nothing — the same answer `expands` gives, reached + /// independently, so a face that somehow rendered a carousel would render an empty one rather + /// than crash on a first page. + @Test("A card with no attachments has no pages") + func aBareCardHasNoPages() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let snapshot = try BoardLoader.load(boardRoot: fixture.root).model + let bare = try card(card2, in: snapshot) + + #expect(CardCarousel.pages(of: bare, boardRoot: fixture.root, laneID: lane1).isEmpty) + } + + /// The folder is built from the ids, so the same card in a different lane resolves to a + /// different path — which is what makes the lane a parameter rather than something this type + /// could guess. + @Test("The attachments folder is the card's own, lane included") + func theAttachmentsFolderIsTheCardsOwn() { + let root = URL(fileURLWithPath: "/tmp/Board.kanban", isDirectory: true) + let here = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane1, cardID: card1) + let there = CardCarousel.attachmentsFolder(boardRoot: root, laneID: lane2, cardID: card1) + #expect(here != there) + #expect(here.pathComponents.suffix(3) == [lane1.rawValue, card1.rawValue, "attachments"]) + } +} + +// MARK: - Paging arithmetic + +@Suite("CardCarousel ▸ paging arithmetic") +struct CardCarouselPagingTests { + + /// The wheel's sign, which is AppKit's: a scroll view advances by *subtracting* the delta, so a + /// negative delta pages forward. + @Test("A wheel tick pages in the direction the delta names") + func aWheelTickPagesInTheDeltaSDirection() { + #expect(CardCarousel.wheelStep(deltaX: 0, deltaY: -3) == 1) + #expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 3) == -1) + } + + /// A tick carrying nothing moves nothing — and, in the view, is handed back to the scroll view + /// rather than eaten. + @Test("A tick with no delta pages nothing") + func anEmptyTickPagesNothing() { + #expect(CardCarousel.wheelStep(deltaX: 0, deltaY: 0) == 0) + } + + /// A horizontal tick — a tilt wheel, or ⇧ with a plain one — wins, because it names this + /// carousel's own axis. + @Test("A horizontal tick outranks a vertical one") + func aHorizontalTickOutranksAVerticalOne() { + #expect(CardCarousel.wheelStep(deltaX: -5, deltaY: 20) == 1) + #expect(CardCarousel.wheelStep(deltaX: 5, deltaY: -20) == -1) + } + + /// **Clamped, never wrapped**: a carousel is a short flat list, and paging past its last + /// attachment back to its first would make "how many are there" unanswerable by paging. + @Test("Paging clamps at both ends") + func pagingClampsAtBothEnds() { + #expect(CardCarousel.page(from: 0, step: 1, count: 3) == 1) + #expect(CardCarousel.page(from: 2, step: 1, count: 3) == 2) + #expect(CardCarousel.page(from: 0, step: -1, count: 3) == 0) + #expect(CardCarousel.page(from: 2, step: -1, count: 3) == 1) + } + + /// The degenerate inputs a live board can hand it — an emptied `attachments/` between a render + /// and a wheel tick — answer zero rather than trapping on a negative index. + @Test("An empty carousel pages to nothing") + func anEmptyCarouselPagesToNothing() { + #expect(CardCarousel.page(from: 0, step: 1, count: 0) == 0) + #expect(CardCarousel.page(from: 4, step: -1, count: 0) == 0) + } +} + +// MARK: - The thumbnail cache key + +@Suite("AttachmentThumbnailKey ▸ what a thumbnail is filed under") +struct AttachmentThumbnailKeyTests { + + private let path = "/tmp/Board.kanban/lane/card/attachments/shot.png" + private let stamp = Date(timeIntervalSince1970: 1_700_000_000) + + /// **Widths bucket to whole points.** A card face is laid out by a `Layout` and its content + /// width can land on a fraction; a thumbnail regenerated because a card grew by a third of a + /// point would be a cache that never hits. + @Test("A fractional width buckets to a whole point") + func fractionalWidthsBucket() { + let a = AttachmentThumbnailKey.Slot(path: path, width: 220.4) + let b = AttachmentThumbnailKey.Slot(path: path, width: 219.8) + #expect(a == b) + #expect(a.width == 220) + } + + /// A real size change is still a different slot — the thumbnail is generated *at* a size. + @Test("A different width is a different slot") + func differentWidthsAreDifferentSlots() { + #expect(AttachmentThumbnailKey.Slot(path: path, width: 220) + != AttachmentThumbnailKey.Slot(path: path, width: 260)) + } + + /// A zero or negative width — the one frame before the carousel has been measured — floors at + /// one rather than producing a degenerate key. + @Test("A width of zero floors at one point") + func zeroWidthFloorsAtOnePoint() { + #expect(AttachmentThumbnailKey.Slot(path: path, width: 0).width == 1) + #expect(AttachmentThumbnailKey.Slot(path: path, width: -8).width == 1) + } + + /// **The stamp is what makes the cache honest.** A board is a live view over folders anyone may + /// write to, so an entry keyed on the path alone would show a picture of a file that has since + /// been replaced. + @Test("A rewritten file is a different key") + func aRewrittenFileIsADifferentKey() { + let slot = AttachmentThumbnailKey.Slot(path: path, width: 220) + let original = AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024) + + // Same bytes, same everything: the hit a reselection depends on. + #expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024) == original) + + // Edited a second later, and edited in place at the same length within the same second — + // either component alone would miss one of these, which is why both are in the key. + #expect(AttachmentThumbnailKey(slot: slot, modified: stamp.addingTimeInterval(1), size: 1024) != original) + #expect(AttachmentThumbnailKey(slot: slot, modified: stamp, size: 2048) != original) + } + + /// A file that could not be stat'd keys as `(nil, nil)` — a legitimate key that simply misses, + /// which is the ordinary "no thumbnail, show the icon" path rather than a second error surface. + @Test("An unstattable file is a legitimate key") + func anUnstattableFileIsALegitimateKey() { + let slot = AttachmentThumbnailKey.Slot(path: path, width: 220) + let unknown = AttachmentThumbnailKey(slot: slot, modified: nil, size: nil) + #expect(unknown == AttachmentThumbnailKey(slot: slot, modified: nil, size: nil)) + #expect(unknown != AttachmentThumbnailKey(slot: slot, modified: stamp, size: 1024)) + } +} diff --git a/KanbanTests/MotionTests.swift b/KanbanTests/MotionTests.swift index 686f5da..a9a614d 100644 --- a/KanbanTests/MotionTests.swift +++ b/KanbanTests/MotionTests.swift @@ -111,6 +111,16 @@ struct MotionCurveTests { #expect(Motion.laneResizeWindowDuration == 0.2) #expect(Motion.laneResize(reduced: false) == .snappy(duration: Motion.laneResizeWindowDuration)) } + + /// **The carousel expansion is the structural voice** (03-board-ui.md § Motion's first named + /// narrow key). What the change *is* is positional — the card grows and its column-mates slide + /// down — so it takes the general snappy spring rather than the filtering one. Pinned as an + /// identity with `structural` rather than as a literal, because if the two ever diverge it + /// should be a deliberate edit to one line. + @Test func theCarouselExpansionIsTheStructuralVoice() { + #expect(Motion.carouselExpansion(reduced: false) == Motion.structural(reduced: false)) + #expect(Motion.carouselExpansion(reduced: false) != Motion.contentReflow(reduced: false)) + } } // MARK: - Reduce Motion @@ -125,6 +135,7 @@ struct ReduceMotionVariantTests { #expect(Motion.delete(reduced: true) == nil) #expect(Motion.laneResize(reduced: true) == nil) #expect(Motion.contentReflow(reduced: true) == nil) + #expect(Motion.carouselExpansion(reduced: true) == nil) } /// "Appear/disappear is scale + fade (cards scale from ~0.8, lanes ~0.9, combined with opacity)." @@ -139,4 +150,14 @@ struct ReduceMotionVariantTests { #expect(Motion.cardAppearance(reduced: true) == .crossfade) #expect(Motion.laneAppearance(reduced: true) == .crossfade) } + + /// **The carousel is the one appearance with no reduced variant, because it is already the + /// reduced form.** 03 fixes an appear/disappear scale for cards and lanes and for nothing else, + /// and the carousel needs none: the movement is the card growing, which `carouselExpansion` + /// animates and Reduce Motion makes instant, so the content only has to fade in over it. This + /// asserts the parameterless accessor is deliberate rather than an omission. + @Test func theCarouselAppearanceIsTheCrossfadeInBothVariants() { + #expect(Motion.carouselAppearance == .crossfade) + #expect(Motion.carouselAppearance != Motion.cardAppearance(reduced: false)) + } } diff --git a/README.md b/README.md index 72d01cc..fbd2607 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Lanework is in early development. This list tracks what has actually shipped and - **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away. - **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced). + +- **The attachment carousel** — select a single card with files and its face expands in place into a paged carousel, one page per attachment in Finder order: a QuickLook thumbnail for anything previewable, the file's Finder icon while one is being made and instead of one for anything that isn't, and page dots on a glass underlay (solid under Reduce Transparency). Trackpad pan, a click on a dot, and a scroll-wheel tick all page it. It is single-selection only — add a second card to the selection and every carousel folds away — and a rubber band in flight suppresses it outright, so sweeping a band across a row of cards never flickers one open. The expansion animates under a transaction keyed on the sole-selected card and nothing broader, so multi-select churn and the band itself animate nothing at all; Reduce Motion makes the growth instant and leaves the fade. Thumbnails are cached per board window against each file's own bytes, so reselecting a card costs nothing while a file rewritten under the app regenerates. - **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock. - **Drag & drop** — cards, lanes and trash rows all travel as real system drag sessions, so a drag crosses window boundaries, shows the system's own copy badge, and carries a full-size replica of what it picked up. A dashed shadow sits at the exact landing spot and the board reflows to make room; the proposal is pure geometry over an analytically reconstructed resting layout — never measured mid-animation frames — so the shadow is stable rather than jittery, and a lane only reflows once the cursor reaches where the dragged run would actually land, holding its last proposal across the ambiguous stretch in between. Dragging any member of a multi-selection drags the whole selection: N contiguous shadows, one insertion point, landing in flatten order. Locality picks the default the way Finder's volumes do — within a board a drag moves, between boards it copies, with ⌥ forcing copy and ⌘ forcing move and the badge tracking live as the cursor crosses a boundary; a lane reordering inside its own board ignores ⌥ entirely, and a lane copy strips tombstoned cards while a lane move carries them whole. Dragging a trash row onto a lane restores it at the drop position, ⌥ copies it out live instead, and dropping it on another board follows the same copy-out grammar. Lanes taller than their viewport autoscroll from either edge, re-resolving the landing spot on every step so a stationary cursor still lands where the shadow shows. A foreign edit mid-drag re-grounds the drag rather than corrupting the drop: the zones re-derive against each new snapshot, a proposal whose lane was deleted withdraws and a release with none simply cancels, and a drag whose items all vanish dissolves itself. At release the board keeps drawing the dropped arrangement until the write round-trips through the watcher, so nothing snaps back for a frame; every drop is one write bracket — one reload, one commit — whatever the set's size. Files dragged in from Finder join the same dispatch: dropped on a card they copy into its `attachments/` (any type, multi-file, Finder-style renames on collision, the card highlighting while hovered), dropped on lane empty space they become one card per file — titled with the filename minus its extension, that file attached, landing at the drop position with a shadow per card. Tombstoned surfaces are inert to them, and a read-only board or an open inline editor refuses them outright.