import AppKit import SwiftUI /// **The colour rectangle** — a plain clickable swatch (`ColorSwatchControl`, `PickerRect.swift`'s /// shared chrome) that opens a SwiftUI popover holding the background palette grid: **None**, the /// role's own sixteen wells, and an **Other…** row onto the system Colors panel. /// /// ### The owner's reversal (2026-08-10, Pipeline card 5004c540, verbatim) /// /// "...lets do same for color, just a 4:6 ratio rectangle for color, no trigger, a grid of colors, and /// a choice for 'Other' or 'More' to pull up generic color picker." /// /// This retires `ColorComboView` and its `NSMenu` dropdown outright — the swatch-faced two-zone combo /// this file used to hold, whose trigger popped **None**, the role's own entries, an off-palette /// "current" row and **Other…** as menu items. The dropdown's whole pure model (`ColorComboItem`, /// `ColorComboMatch`, `ColorComboMenu`, `ColorComboModel.match`/`.menu`/`.displayName`/ /// `.normalizedHex`) went with it: nothing left calls a menu into being, so nothing here builds one. /// /// `ColorSwatchRole` is the one surviving piece of the old pure model — which of the two palettes a /// swatch offers. The new grid's wells are `role.palette`, matched by **literal name**, exactly the /// rule `StyleEditorView.backgroundWells` already applies to the Style… popover's own background grid /// — reusing that grid's own layout and matching convention rather than the retired dropdown's hex- /// normalizing cleverness, so the app's two background grids agree about what "selected" means. An /// off-palette hex that happens to equal a palette colour numerically no longer highlights that well; /// it did in the old dropdown and does not in the Style… popover's grid either. /// /// ### The "Other…" row and the debounce /// /// Opens `SharedColorPanelSession` — the same shared, pre-debounced (~400ms) Colors-panel takeover /// `StyleEditorView.openBackgroundPanel` and `SymbolPicker.openTintPanel` already use, because this /// swatch's popover is transient exactly like theirs: opening the panel dismisses the popover, so a /// per-view-owned panel session (what `ColorComboView`'s coordinator held) would have nothing left to /// talk to. The write always goes through **`onPanelChange`, never `onChange`'s funnel** — "no /// StyleRecents feed on drag ticks" (`StyleCommand.apply`'s recents feed is keyed to `background` /// specifically, and a Colors-panel pick must never enter it, even the one settled value the debounce /// finally delivers) — `StyleEditorView.openBackgroundPanel`'s own rule, restated here since this door /// opens the identical session. // MARK: - Role /// Which of the two palettes a swatch offers — `Palette.backgrounds` for `background`, the only role /// wired to a caller today; `Palette.foregrounds` stays for symmetry with `ColorSwatchRole`'s own /// pre-2026-08-10 shape, unused but free. enum ColorSwatchRole: Sendable, Equatable { case background case foreground var palette: [PaletteColor] { switch self { case .background: Palette.backgrounds case .foreground: Palette.foregrounds } } } // MARK: - Geometry /// The popover's own grid geometry — `SymbolPickerLayout`'s pattern, restated for a plain colour /// grid: the well side and spacing read off `StyleEditorLayout`'s statics, scaled up by the same /// `gridScale` the symbol popover's grid uses, and **seven columns** — `StyleEditorLayout.popover`'s /// own settled fit for None + sixteen wells (7 + 7 + 3), reused here rather than re-derived, since it /// is exactly this count of wells again. struct ColorSwatchPopoverLayout: Equatable { static let columns = 7 /// The grid's enlargement over the style editor's well size — `SymbolPickerLayout.gridScale`'s /// own figure, so the two new-since-2026-08-10 popovers read at the same scale. static let gridScale: CGFloat = 1.3 var wellSide: CGFloat var wellSpacing: CGFloat /// The popover's own inset, on all four sides, and the gap between the grid and the **Other…** /// row below it. var contentPadding: CGFloat var gridWidth: CGFloat /// The grid's width plus its padding on both sides — the popover's fixed width. var popoverWidth: CGFloat static func metrics(bodyPointSize: CGFloat) -> ColorSwatchPopoverLayout { let side = (StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) * gridScale).rounded() let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize) let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize) let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded() return ColorSwatchPopoverLayout( wellSide: side, wellSpacing: spacing, contentPadding: padding, gridWidth: gridWidth, popoverWidth: (gridWidth + padding * 2).rounded() ) } } // MARK: - The picker /// The public surface: a colour rectangle plus its popover, `SymbolPicker`'s own shape one dimension /// over. No store, no undo stack, no target — `onChange`/`onPanelChange` are the whole contract, the /// same split `ColorComboView`'s `onChange`/`onPanelChange` offered, so `CardStyleSection` keeps its /// two-closure wiring across the control swap. struct ColorSwatchPicker: View { let role: ColorSwatchRole /// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field /// carries it. let value: String? var isEnabled: Bool = true /// A discrete pick — a grid well or **None**. Fired once, synchronously; the caller commits it /// immediately and decides for itself whether it feeds `StyleRecents`. let onChange: (String?) -> Void /// A value settled from the Colors panel's **Other…** door — always raw, never through the /// recents funnel (this file's own header). let onPanelChange: (String?) -> Void /// The popover's presented flag — view-local `@State`, `SymbolPicker.isPopoverPresented`'s own /// reasoning: a plain SwiftUI `.popover` nests correctly inside another one (the board popover /// mounts a symbol rectangle the identical way), where a hand-driven `NSPopover` would not. @State private var isPopoverPresented = false var body: some View { ColorSwatchView(value: value, isEnabled: isEnabled, onClick: { isPopoverPresented = true }) .help(helpText) .accessibilityLabel(helpText) .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { ColorSwatchPopoverContent( current: value, palette: role.palette, layout: .metrics(bodyPointSize: CardWindowMetrics.bodyPointSize), onSelect: { picked in isPopoverPresented = false onChange(picked) }, onPickOther: { isPopoverPresented = false openColorPanel() } ) } } private var helpText: String { role == .background ? "Background" : "Color" } /// **Other…** — the shared, debounced Colors panel, for the same outlives-the-popover reason /// `SymbolPicker.openTintPanel` opens it: dismissing this popover would take a view-owned /// coordinator with it before the panel finished streaming. private func openColorPanel() { SharedColorPanelSession.present( seed: value.flatMap(Palette.nsColor(for:)), matching: role.palette ) { picked in onPanelChange(picked) } } } // MARK: - The AppKit bridge /// `ColorSwatchPicker`'s face: a `ColorSwatchControl` with its one zone wired to the caller's /// closure. Deliberately dumb, `SymbolPicker`'s own `SymbolGlyphView` split: every decision about /// what a click means lives in SwiftUI, where the popover's presented state already does. private struct ColorSwatchView: NSViewRepresentable { let value: String? let isEnabled: Bool let onClick: () -> Void func makeNSView(context: Context) -> ColorSwatchControl { ColorSwatchControl(frame: .zero) } func updateNSView(_ control: ColorSwatchControl, context: Context) { control.metrics = .current control.isEnabled = isEnabled control.swatchValue = value control.accessibilityValueText = value ?? "None" control.onClick = onClick } /// Obeys an explicit finite proposal when SwiftUI hands one over, else falls back to the /// control's own intrinsic width — `SymbolGlyphView.sizeThatFits`'s rule, restated so the two /// rectangles answer a proposal identically. func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorSwatchControl, context: Context) -> CGSize? { let width: CGFloat if let proposed = proposal.width, proposed.isFinite { width = proposed } else { width = nsView.intrinsicContentSize.width } return CGSize(width: width, height: nsView.intrinsicContentSize.height) } } // MARK: - The control /// `PickerRectControl` with a **swatch** filling the whole face — the colour half of the shared /// chrome, and the whole of what is specific to colour about it. final class ColorSwatchControl: PickerRectControl { /// The value the face currently draws — a palette name or a hand-written hex, handed over on /// every SwiftUI update. var swatchValue: String? { didSet { guard swatchValue != oldValue else { return } needsDisplay = true } } /// The colour rect: a `textBackgroundColor` underlay so a translucent stored colour composites /// the same way in light and dark, the resolved colour on top, a `separatorColor` hairline /// stroke last. `nil`/unresolvable value → underlay + stroke only, the same "there is no colour, /// so show none" rule the retired combo's face drew — straight into the control's own graphics /// context, exactly as before, rather than through `PaletteSwatch.rectImage`'s intermediate /// `NSImage` (that function had exactly one caller, the retired dropdown's menu rows, and is /// gone with it). override func drawFace(in rect: NSRect) { guard rect.width > 0, rect.height > 0 else { return } let path = NSBezierPath(roundedRect: rect, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius) NSColor.textBackgroundColor.setFill() path.fill() if let swatchValue, let color = Palette.nsColor(for: swatchValue) { color.setFill() path.fill() } NSColor.separatorColor.setStroke() path.lineWidth = 1 path.stroke() } } // MARK: - The popover's content /// The popover's body: the palette grid, then **Other…** — `SymbolPickerPopoverContent`'s shape, one /// grid narrower (no search, no second grid): this popover has exactly one dimension to offer. private struct ColorSwatchPopoverContent: View { let current: String? let palette: [PaletteColor] let layout: ColorSwatchPopoverLayout let onSelect: (String?) -> Void let onPickOther: () -> Void var body: some View { VStack(alignment: .leading, spacing: layout.wellSpacing) { ColorSwatchWellGrid(current: current, palette: palette, layout: layout, onSelect: onSelect) // Trailing-aligned link row onto the standalone panel — `SymbolPickerPopoverContent. // deeperRow`'s own shape, restated: a well that is not a value is a well that has to be // learned, and the row already spells this door as a titled affordance. HStack(spacing: 0) { Spacer(minLength: 0) Button("Other…", action: onPickOther) .buttonStyle(.link) .font(.caption) } } .padding(layout.contentPadding) .frame(width: layout.popoverWidth) } } // MARK: - Wells /// One well in the grid: a palette name, or `nil` for the leading **None**. private struct ColorSwatchWell: Identifiable { let id: Int let name: String? let label: String let isSelected: Bool } /// The palette grid: the leading **None** well and `palette`'s own entries, seven wide — /// `StyleWellGrid`'s pattern (StyleEditor.swift), mirrored rather than shared for the reason /// `SymbolWellGrid`/`SymbolColorGrid` (SymbolPicker.swift) already mirror it instead of widening that /// file's `private` access for one caller outside it. private struct ColorSwatchWellGrid: View { let current: String? let palette: [PaletteColor] let layout: ColorSwatchPopoverLayout let onSelect: (String?) -> Void @FocusState private var focused: Int? @Environment(\.colorSchemeContrast) private var contrast var body: some View { LazyVGrid( columns: Array( repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing), count: ColorSwatchPopoverLayout.columns ), spacing: layout.wellSpacing ) { ForEach(wells) { well in Button { onSelect(well.name) } label: { swatch(well.name.flatMap(Palette.color(named:))) .overlay(selectionRing(well.isSelected)) .contentShape(Rectangle()) } .buttonStyle(.plain) .focusable() .focused($focused, equals: well.id) .help(well.label) .accessibilityLabel(well.label) .accessibilityAddTraits(well.isSelected ? [.isSelected] : []) } } .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in move(press.key) } } /// **None** leads, selected whenever `current` is absent — literal-name matching only /// (`ColorSwatchPicker`'s own header), so an off-palette hex leaves every well unselected exactly /// as `StyleEditorView.backgroundWells` already does. private var wells: [ColorSwatchWell] { var wells = [ColorSwatchWell(id: 0, name: nil, label: "None", isSelected: current == nil)] for (index, color) in palette.enumerated() { wells.append(ColorSwatchWell( id: index + 1, name: color.name, label: color.name, isSelected: current == color.name )) } return wells } /// A colour swatch, always stroked (`chalk` is `#FFFFFF`; an unbordered white swatch is an /// invisible control), with the corner-to-corner slash standing in for "no colour" on the None /// well — `StyleWellFace`'s own vocabulary, restated. private func swatch(_ color: Color?) -> some View { RoundedRectangle(cornerRadius: cornerRadius) .fill(color ?? Color(nsColor: .textBackgroundColor)) .overlay { if color == nil { ColorSwatchNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded())) .stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast)) } } .overlay( RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast)) ) .frame(width: layout.wellSide, height: layout.wellSide) } private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.2).rounded()) } private func selectionRing(_ isSelected: Bool) -> some View { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder( isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: Accommodations.borderWidth(2, contrast: contrast) ) .padding(-Accommodations.borderWidth(2, contrast: contrast) / 2) } /// `SymbolWellGrid.move(_:)`'s rule, at this grid's own seven columns. private func move(_ key: KeyEquivalent) -> KeyPress.Result { let delta: Int switch key { case .leftArrow: delta = -1 case .rightArrow: delta = 1 case .upArrow: delta = -ColorSwatchPopoverLayout.columns case .downArrow: delta = ColorSwatchPopoverLayout.columns default: return .ignored } let current = focused ?? 0 let next = min(max(0, current + delta), wells.count - 1) focused = next return .handled } } /// The None well's corner-to-corner slash — `StyleEditor.swift`'s `NoValueStrike`/`SymbolPicker. /// swift`'s `ColorNoneStrike`, restated as a third sibling for the same reason those two are already /// siblings rather than one shared exported shape. private struct ColorSwatchNoneStrike: Shape { let inset: CGFloat func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset)) path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset)) return path } }