import AppKit import SwiftUI /// **The board popover's Background tab** (03-board-ui.md § Board popover ▸ Background tab, settled /// 2026-08-07) — two surfaces sharing one tab, one manual and one generated. /// /// ### Color /// /// The `StyleEditorView` embed, re-homed here unchanged from the pre-tab popover body: same target /// (`.board`), same `showsSymbols: false` (the inline `SymbolPicker` beside the rename field owns the /// board's glyph, so a second symbol grid here would make the glyph read as two settings), same write /// path (`StyleCommand.apply` → `BoardStore.applyStyle` + `StyleRecents.record`). It brings its own /// header ("Background") and its own inset (`StyleEditorLayout.popover(...).padding`), so nothing /// here pads around it — the anti-double-pad rule `BoardInfoView.inset`'s doc names. /// /// ### Generated /// /// A `FacetsRecipe` names a picture (`Backgrounds/FacetsRecipe.swift`); this section is the four /// filters that narrow one (tone, hue strategy, mesh density, saturation) plus one seed per hue, /// minted fresh on appear and re-minted by Reroll. Every 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). // MARK: - Filters /// **The generated picker'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 (`BoardBackgroundFiltersTests`). struct BoardBackgroundFilters: 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) -> BoardBackgroundFilters { BoardBackgroundFilters( 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". 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 BoardBackgroundTabView: View { let store: BoardStore let recents: StyleRecents /// The popover's own padding figure (`BoardInfoView.inset`), matching `BoardInfoTabView`'s own /// parameter — everything here that does not already carry its own inset (the Generated section) /// pads by this amount instead of restating the derivation. let inset: CGFloat @Environment(\.colorScheme) private var colorScheme @Environment(\.colorSchemeContrast) private var contrast @State private var filters = BoardBackgroundFilters.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). @State private var seeds: [FacetsRecipe.Hue: UInt64] = [:] /// The carousel's previews, keyed by hue — absent until the render for the current /// `(filters, seeds)` pair lands, which is what the placeholder chip is for. @State private var previews: [FacetsRecipe.Hue: CGImage] = [:] /// Whether a 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. @State private var isApplying = false private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } private let swatchWidth: CGFloat = 96 private var swatchHeight: CGFloat { (swatchWidth * 10 / 16).rounded() } private let swatchCornerRadius: CGFloat = 6 var body: some View { VStack(alignment: .leading, spacing: 0) { // Always the board, whatever is selected. The ⌥⌘S anchor is the selection-aware one // ("nothing selected = the board"); this embed is the surface that exists *because* the // board is a style target, so it can have no other target (§ Styling ▸ Controls: "the // board popover's target is the board itself"). No symbol section — the inline // `SymbolPicker` beside the rename field above owns the board glyph. StyleEditorView(store: store, recents: recents, target: .board, showsSymbols: false) Divider() generatedSection .padding(inset) } .onAppear { filters.tone = BoardBackgroundFilters.defaultTone(colorScheme: colorScheme) if seeds.isEmpty { seeds = Self.mintSeeds() } } .task(id: previewKey) { await renderPreviews() } } // MARK: - Generated section private var generatedSection: some View { VStack(alignment: .leading, spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) { HStack(spacing: 6) { sectionHeader("Generated") if isApplying { ProgressView() .controlSize(.small) .accessibilityLabel("Applying") } Spacer() Button { reroll() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .accessibilityLabel("New variations") } Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 8, verticalSpacing: 6) { filterRow("Tone", selection: $filters.tone) { $0 == .light ? "Light" : "Dark" } filterRow("Colors", selection: $filters.colors, label: colorsLabel) filterRow("Mesh", selection: $filters.mesh, label: meshLabel) filterRow("Saturation", selection: $filters.saturation, label: saturationLabel) } carousel } .font(.callout) // The whole section disables as one surface under the read-only lock — the same coarse rule // `StyleEditorView` applies to itself just above: an editor whose gestures would be refused // should not look available, and there is nothing here worth half-enabling (a filter nobody // can commit is not a useful control to leave live). .disabled(!store.acceptsBoardMutations) } private func sectionHeader(_ title: String) -> some View { Text(title) .font(.subheadline.weight(.semibold)) } /// 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 private var carousel: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: StyleEditorLayout.wellSpacing(bodyPointSize: pointSize)) { ForEach(FacetsRecipe.Hue.allCases, id: \.self) { hue in swatch(hue) } } } } private func swatch(_ hue: FacetsRecipe.Hue) -> some View { Button { apply(hue) } label: { swatchFace(hue) } .buttonStyle(.plain) // Layered on top of the section's own disabling: this half additionally freezes every swatch // for the one gesture already running, so a second click cannot race the first's write. .disabled(isApplying) .opacity(isApplying ? 0.6 : 1) .help(hue.displayName) .accessibilityLabel("\(hue.displayName) — set generated background") } @ViewBuilder private func swatchFace(_ hue: FacetsRecipe.Hue) -> some View { Group { 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)) ) } 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: BoardBackgroundFilters var seeds: [FacetsRecipe.Hue: UInt64] } private var previewKey: PreviewKey { PreviewKey(filters: filters, seeds: seeds) } /// The strip's eight previews, off the main actor — `FacetsGenerator.render` is pure, so this is /// exactly the render `apply(_:)` 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. 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: 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 apply(_ 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" } } }