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