import AppKit import SwiftUI /// **The board popover's Theme tab** (03-board-ui.md § Board popover ▸ Theme tab, settled 2026-08-07) /// — reworked, same day, from the Background tab it replaces: a "Solid color" vs "Pattern" picker /// over one shared Tone/Saturation state, rather than a manual colour grid stacked over a /// generated-only picker. /// /// ### No manual grid here /// /// The `StyleEditorView` embed this tab carried at first — the palette grid, its own "Board" /// subtitle, the None well — is gone. Manual board styling stays reachable through **Style… ⌥⌘S** /// (`StyleEditorPopover`), the anchor every other target already uses; nothing is lost, and this tab /// no longer says "Board" about a surface that is, self-evidently, this board's own popover. /// /// ### Solid color and Pattern share one filters struct /// /// `BoardThemeFilters` carries all four axes — tone, hue strategy, mesh density, saturation — but /// Solid color only ever reads two of them (tone, saturation): a solid swatch is a `FacetsRecipe`'s /// `primaryColor` at the filters' own tone/saturation level, seed-independent by construction /// (`FacetsRecipe.primaryColor` never reads `seed`). Switching modes leaves tone and saturation /// exactly where they were — a picker that reset them on every mode flip would make "Pattern, then /// back to Solid" lose a choice nobody asked to undo. /// /// ### One carousel, two fills /// /// The eight-hue strip is one component in both modes: Pattern's swatches are the rendered mesh /// previews this tab has always shown (`FacetsGenerator.render`, 384px); Solid's are flat fills of /// the same eight hues, in the same wheel order, at the same tone/saturation. **Chevrons page it** — /// `chevron.compact.left`/`.right`, tall and narrow, flanking the scroll window — because eight /// swatches at this width run past the popover's edge and a strip with no other way to say "there is /// more here" than a fade nobody can click is not a discoverable one. /// /// Every Pattern swatch previews the exact recipe a click would apply — same filters, same seed, only /// the pixel width differs (384 for the strip, 3072 for the file) — so "what's clicked is what lands" /// (`FacetsGenerator`'s own claim) still holds. A Solid swatch's claim is stronger still: there is no /// seed to differ on, so the swatch *is* the colour, exactly. // MARK: - Mode /// **Solid color vs Pattern** — the tab's top control (`BoardThemeTabView`). Raw values are the /// segmented picker's own labels, so this enum needs no separate label function the way the filter /// rows below do. enum BoardThemeMode: String, CaseIterable, Identifiable, Hashable { case solid = "Solid color" case pattern = "Pattern" var id: Self { self } } // MARK: - Filters /// **The Theme tab's filter state**, and the pure mapping from it (plus a hue and a seed) to a /// `FacetsRecipe` — pulled out of the view so the default-tone rule and the recipe assembly are each /// assertable without a popover on screen (`BoardThemeFiltersTests`). /// /// Shared by both modes (`BoardThemeMode`): Solid color reads `tone` and `saturation` alone, Pattern /// reads all four. A mode switch never resets this struct — that is what keeps a chosen tone and /// saturation live across "Pattern, then Solid, then Pattern again". struct BoardThemeFilters: Equatable { var tone: FacetsRecipe.Tone var colors: FacetsRecipe.Strategy var mesh: FacetsRecipe.Density var saturation: FacetsRecipe.Saturation /// The picker's opening state: mono colours, medium mesh, mid saturation always — only tone /// follows the system, which is `defaultTone(colorScheme:)`'s own job. static func initial(colorScheme: ColorScheme) -> BoardThemeFilters { BoardThemeFilters( tone: defaultTone(colorScheme: colorScheme), colors: .mono, mesh: .medium, saturation: .mid ) } /// Light appearance opens on Tone Light, dark on Tone Dark — read once, at first appearance, so a /// picker opened on a dark-mode Mac starts on swatches that read correctly against the popover /// around them rather than ones chosen for the other appearance. `.light` covers every /// `ColorScheme` case but `.dark` — there is no third case today. static func defaultTone(colorScheme: ColorScheme) -> FacetsRecipe.Tone { colorScheme == .dark ? .dark : .light } /// One hue's recipe under these filters and a given seed — the whole of "click a swatch, get a /// board". Pattern's clicks pass a minted seed; Solid's pass `0` and never look at it /// (`FacetsRecipe.primaryColor` is seed-independent). func recipe(hue: FacetsRecipe.Hue, seed: UInt64) -> FacetsRecipe { FacetsRecipe(hue: hue, strategy: colors, density: mesh, tone: tone, saturation: saturation, seed: seed) } } // MARK: - The tab struct BoardThemeTabView: View { let store: BoardStore /// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own /// parameter — everything here pads by this amount instead of restating the derivation. let inset: CGFloat @Environment(\.colorScheme) private var colorScheme @Environment(\.colorSchemeContrast) private var contrast /// Solid color vs Pattern. Defaulted in `init`, not `onAppear`: unlike the tone default below, /// which needs the colour-scheme environment and so can only be read once the view is actually on /// screen, `store.snapshot` is already loaded the moment this view is built, so there is no reason /// to let the picker flash open on one mode and settle on another a frame later. @State private var mode: BoardThemeMode @State private var filters = BoardThemeFilters.initial(colorScheme: .light) /// One seed per hue, in wheel order — minted on appear and re-minted by Reroll. A filter change /// leaves these alone (same geometry, new treatment); Reroll is the one gesture that changes them /// (new geometry). Solid color never reads this — its swatches are seed-independent by /// construction — but the dictionary is still minted eagerly so Pattern has seeds ready the /// instant a user switches to it. @State private var seeds: [FacetsRecipe.Hue: UInt64] = [:] /// The carousel's Pattern previews, keyed by hue — absent until the render for the current /// `(filters, seeds)` pair lands, which is what the placeholder chip is for. Unused in Solid mode. @State private var previews: [FacetsRecipe.Hue: CGImage] = [:] /// Whether a Pattern swatch's 3072px render is in flight — every swatch disables and the header /// grows a small spinner for the duration, so a second click cannot race the first. Solid applies /// synchronously (there is no picture to render), so this never goes true for a Solid click. @State private var isApplying = false /// The carousel's paging position, in swatch indices — what the chevrons move and what disables /// them at either end of the eight-hue strip (`carousel`). @State private var scrollIndex = 0 private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } private let swatchWidth: CGFloat = 96 private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() } private let swatchCornerRadius: CGFloat = 6 /// The chevrons' own width — narrow enough to read as a paging control rather than a third /// swatch, per `chevron.compact`'s own "tall and skinny" form. private let chevronWidth: CGFloat = 15 private var lastSwatchIndex: Int { FacetsRecipe.Hue.allCases.count - 1 } init(store: BoardStore, inset: CGFloat) { self.store = store self.inset = inset // The simplest honest default: a board whose image *is* this generator's own output opens on // Pattern, since that is the surface that made it; every other board — no image, or a // hand-placed one the app does not own — opens on Solid color, which is also the mode that // never overwrites a foreign picture by accident. _mode = State(initialValue: store.snapshot.backgroundImage.value == FacetsGenerator.fileName ? .pattern : .solid) } var body: some View { VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) { modePicker Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) { filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" } if mode == .pattern { filterRow("Colors", selection: $filters.colors, label: colorsLabel) filterRow("Mesh", selection: $filters.mesh, label: meshLabel) } filterRow("Saturation", selection: $filters.saturation, label: saturationLabel) } carouselAccessories carousel } .padding(inset) .font(.callout) // The whole tab disables as one surface under the read-only lock — the same coarse rule // `StyleEditorView` applies to itself: an editor whose gestures would be refused should not // look available, and there is nothing here worth half-enabling. .disabled(!store.acceptsBoardMutations) .onAppear { filters.tone = BoardThemeFilters.defaultTone(colorScheme: colorScheme) if seeds.isEmpty { seeds = Self.mintSeeds() } } .task(id: previewKey) { await renderPreviews() } } // MARK: - Mode picker private var modePicker: some View { Picker("Theme", selection: $mode) { ForEach(BoardThemeMode.allCases) { value in Text(value.rawValue).tag(value) } } .pickerStyle(.segmented) .labelsHidden() } // MARK: - Filter rows /// One filter: a trailing-aligned label — matching the Info tab's row shape — beside a compact /// segmented picker. The label is hidden from VoiceOver; the picker's own title (identical text) /// is what it announces, so nothing is read twice. private func filterRow( _ title: String, selection: Binding, label: @escaping (Value) -> String ) -> some View where Value: Hashable, Value: CaseIterable, Value.AllCases: RandomAccessCollection { GridRow { Text(title) .foregroundStyle(.secondary) .gridColumnAlignment(.trailing) .accessibilityHidden(true) Picker(title, selection: selection) { ForEach(Array(Value.allCases), id: \.self) { value in Text(label(value)).tag(value) } } .pickerStyle(.segmented) .labelsHidden() .controlSize(.small) } } private func colorsLabel(_ value: FacetsRecipe.Strategy) -> String { switch value { case .mono: "Mono" case .duo: "Duo" case .trio: "Trio" } } private func meshLabel(_ value: FacetsRecipe.Density) -> String { switch value { case .coarse: "Coarse" case .medium: "Medium" case .fine: "Fine" } } private func saturationLabel(_ value: FacetsRecipe.Saturation) -> String { switch value { case .soft: "Soft" case .mid: "Mid" case .rich: "Rich" } } // MARK: - Carousel accessories /// The spinner and the Reroll button — Reroll only matters in Pattern mode (Solid color has no /// geometry to reroll), so it is hidden rather than disabled in Solid mode, on the same "nothing /// here worth half-enabling" reasoning the tab's own `.disabled` applies. private var carouselAccessories: some View { HStack(spacing: 6) { Spacer() if isApplying { ProgressView() .controlSize(.small) .accessibilityLabel("Applying") } if mode == .pattern { Button { reroll() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .accessibilityLabel("New variations") } } } // MARK: - Carousel /// The eight-hue strip, flanked by paging chevrons — one component for both modes /// (`swatchFace(_:)` is the only thing that reads `mode`). /// /// **Chevrons page by index, not by offset.** `scrollIndex` names the swatch nearest the leading /// edge; a press moves it three hues (roughly three swatch widths, since consecutive swatches are /// laid out one `swatchWidth + spacing` apart) and clamps at either end of the wheel, which is /// also what disables a chevron that has nothing left to reveal. `ScrollViewProxy.scrollTo` — not /// the newer `scrollPosition(id:)` binding — because a proxy scroll is a one-shot "go there" the /// tracked index already drives, while the binding form exists to *report back* which item is /// visible, a question this carousel never asks. private var carousel: some View { ScrollViewReader { proxy in HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) { chevron( systemName: "chevron.compact.left", accessibilityLabel: "Earlier hues", isDisabled: scrollIndex <= 0 ) { page(by: -3, proxy: proxy) } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) { ForEach(Array(FacetsRecipe.Hue.allCases.enumerated()), id: \.offset) { index, hue in swatch(hue) .id(index) } } } chevron( systemName: "chevron.compact.right", accessibilityLabel: "Later hues", isDisabled: scrollIndex >= lastSwatchIndex ) { page(by: 3, proxy: proxy) } } } } /// One paging button — tall and narrow, full swatch height, vertically centered beside the scroll /// window. `chevron.compact.left`/`.right` are SF Symbols' own tall-skinny variants, so no custom /// shape is needed to get the form the design asks for. private func chevron( systemName: String, accessibilityLabel: String, isDisabled: Bool, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemName) .frame(width: chevronWidth, height: swatchHeight) .contentShape(Rectangle()) } .buttonStyle(.borderless) .disabled(isDisabled) .help(accessibilityLabel) .accessibilityLabel(accessibilityLabel) } /// Moves `scrollIndex` by `delta`, clamped to the strip's ends, and animates the scroll view to /// the swatch that lands on — a no-op past either end, which is what the chevron's own `disabled` /// state already promises but costs nothing to restate here. private func page(by delta: Int, proxy: ScrollViewProxy) { let newIndex = min(max(scrollIndex + delta, 0), lastSwatchIndex) guard newIndex != scrollIndex else { return } scrollIndex = newIndex withAnimation { proxy.scrollTo(newIndex, anchor: .leading) } } private func swatch(_ hue: FacetsRecipe.Hue) -> some View { Button { apply(hue) } label: { swatchFace(hue) } .buttonStyle(.plain) // Layered on top of the tab's own disabling: this half additionally freezes every swatch for // the one Pattern render already running, so a second click cannot race the first's write. // Always `false` in Solid mode, where nothing is ever in flight. .disabled(isApplying) .opacity(isApplying ? 0.6 : 1) .help(hue.displayName) .accessibilityLabel(accessibilityLabel(for: hue)) } private func accessibilityLabel(for hue: FacetsRecipe.Hue) -> String { switch mode { case .solid: "\(hue.displayName) — set solid background" case .pattern: "\(hue.displayName) — set generated background" } } @ViewBuilder private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View { Group { switch mode { case .solid: solidColor(hue) case .pattern: if let image = previews[hue] { Image(decorative: image, scale: 1) .resizable() .aspectRatio(contentMode: .fill) } else { // The pending placeholder: the recipe's own primary colour, so the strip reads as // "still painting this picture" rather than as a hole — and is already the right // colour if the render never manages to beat a quick reroll. chip(hue) } } } .frame(width: swatchWidth, height: swatchHeight) .clipShape(RoundedRectangle(cornerRadius: swatchCornerRadius)) .overlay( RoundedRectangle(cornerRadius: swatchCornerRadius) .strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast)) ) } /// A Solid color swatch's fill — the filters' own tone/saturation level at this hue, seed `0` and /// seed-independent (`FacetsRecipe.primaryColor` never reads it): the same colour every time, for /// the same filters and hue, which is the whole of what makes a flat swatch honest about what /// clicking it applies. private func solidColor(_ hue: FacetsRecipe.Hue) -> Color { let components = filters.recipe(hue: hue, seed: 0).primaryColor.components return Color(red: components.red, green: components.green, blue: components.blue) } private func chip(_ hue: FacetsRecipe.Hue) -> Color { guard let seed = seeds[hue] else { return Color(nsColor: .textBackgroundColor) } let components = filters.recipe(hue: hue, seed: seed).primaryColor.components return Color(red: components.red, green: components.green, blue: components.blue) } // MARK: - Rendering and applying private struct PreviewKey: Equatable { var filters: BoardThemeFilters var seeds: [FacetsRecipe.Hue: UInt64] } private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) } /// The strip's eight Pattern previews, off the main actor — `FacetsGenerator.render` is pure, so /// this is exactly the render `applyPattern(_:)` below would do at 3072px, just smaller and for /// every hue at once. Re-runs whenever `previewKey` changes (`.task(id:)`), which a filter edit or /// a Reroll both do — the same detach-and-await shape `BoardInfoTabView`'s disk walk uses. /// /// Runs regardless of `mode`: tone and saturation are shared, so a filter edited while Solid color /// is showing still has to leave Pattern's previews correct for the instant the user switches back /// to it, and eight 384px renders are cheap enough that gating them on the visible mode would save /// little for the complexity it would cost. private func renderPreviews() async { previews = [:] let filters = self.filters let seeds = self.seeds let rendered = await Task.detached(priority: .utility) { () -> [FacetsRecipe.Hue: CGImage] in var rendered: [FacetsRecipe.Hue: CGImage] = [:] for hue in FacetsRecipe.Hue.allCases { guard let seed = seeds[hue] else { continue } if let image = FacetsGenerator.render(recipe: filters.recipe(hue: hue, seed: seed), pixelWidth: 384) { rendered[hue] = image } } return rendered }.value // `.task(id:)` cancels this task when the key moves on, but cancellation is cooperative and // the detached render finishes regardless — without this gate a slow stale strip could land // *after* the newer key's own renders and quietly replace them. guard !Task.isCancelled else { return } previews = rendered } /// A swatch, clicked — Solid color writes a colour and clears the image subkey synchronously; /// Pattern renders and writes a picture off the main actor, exactly as this tab always has. private func apply(_ hue: FacetsRecipe.Hue) { switch mode { case .solid: applySolid(hue) case .pattern: applyPattern(hue) } } /// The Solid color half: no render, no detour off the main actor — just the colour this hue's /// level names, through the one gesture every solid background lands through /// (`BoardStore.applySolidBackground`). A refused write under the lock leaves the board exactly as /// it was; the write path's own banners cover that half. private func applySolid(_ hue: FacetsRecipe.Hue) { let colorHex = filters.recipe(hue: hue, seed: 0).primaryColorHex _ = store.applySolidBackground(colorHex: colorHex) } /// The Pattern half: the same recipe the preview showed, rendered at the file's own width and /// written through the one gesture every generated background lands through /// (`BoardStore.applyGeneratedBackground`). Failures — a `nil` render, a refused write under the /// lock — leave the board exactly as it was; the write path's own banners cover the write half. private func applyPattern(_ hue: FacetsRecipe.Hue) { guard let seed = seeds[hue] else { return } let recipe = filters.recipe(hue: hue, seed: seed) isApplying = true Task { let data = await Task.detached(priority: .userInitiated) { FacetsGenerator.pngData(recipe: recipe, pixelWidth: 3072) }.value if let data { _ = store.applyGeneratedBackground(png: data, colorHex: recipe.primaryColorHex) } isApplying = false } } private static func mintSeeds() -> [FacetsRecipe.Hue: UInt64] { Dictionary(uniqueKeysWithValues: FacetsRecipe.Hue.allCases.map { ($0, UInt64.random(in: UInt64.min...UInt64.max)) }) } private func reroll() { seeds = Self.mintSeeds() } } // MARK: - Hue display names private extension FacetsRecipe.Hue { /// The wheel's own names, capitalized for the carousel's `.help` and accessibility label — the /// same eight words 03-board-ui.md's faceted-gallery notes use. var displayName: String { switch self { case .clay: "Clay" case .amber: "Amber" case .olive: "Olive" case .forest: "Forest" case .teal: "Teal" case .sky: "Sky" case .iris: "Iris" case .rose: "Rose" } } }