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) } }