343 lines
14 KiB
Swift
343 lines
14 KiB
Swift
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<Value>(
|
|
_ title: String,
|
|
selection: Binding<Value>,
|
|
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"
|
|
}
|
|
}
|
|
}
|