The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs

Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app
escape hatch is ratified in full, reversing 2026-07-29's palette-only rule —
the combo's Other… opens the system Colors panel, a pick landing on a palette
color stores the name, anything else the hex. Free-picked colors change no
contrast story: they land on the same runtime ink computation hand-written hex
always got (10 amended to say so; no warning surface is owed). Anchor
ownership: the card sidebar's background story is the combo alone — the well
grid's background half stays with the other anchors (StyleEditorView gains
showsBackground beside showsSymbols; the popover's symbol half already went to
its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a
panel pick never enters them.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-06 22:05:48 -04:00
parent 73698cd77b
commit 9766e1f61c
9 changed files with 981 additions and 18 deletions
+614
View File
@@ -0,0 +1,614 @@
import AppKit
import SwiftUI
/// A reusable colour-picker combo: a collapsed face split into **two zones**, Xcode's inspector
/// colour combo's own shape a flat swatch of the current value filling almost the whole control,
/// and a narrow chevron trigger at the trailing edge. Clicking the swatch opens
/// `NSColorPanel.shared` directly; clicking the trigger pops a dropdown of **None**, the role's
/// twelve palette colours, an off-palette current value stated verbatim when there is one, and
/// **Other**, which hands off to that same panel the swatch and **Other** are two doors onto
/// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`).
///
/// It is the second surface `background`/`iconColor` can be set from, beside the well grid
/// (`StyleEditor.swift`'s `StyleEditorView`) the well grid stays exactly as it is; this is a
/// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s
/// own labeled row).
///
/// ### Two halves, the same split every other file here draws
///
/// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model**
/// item lists, selection matching, hex normalization, display-name casing every rule a test can
/// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and
/// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`.
/// (Its collapsed face has its *own*, unrelated two-zone split swatch versus trigger,
/// `ColorComboControl`'s own doc comment which has nothing to do with this pure-model/view one.)
// MARK: - Role
/// Which of the two palettes a combo offers `Palette.backgrounds` for `background`,
/// `Palette.foregrounds` for `iconColor`/icon tints. Both tables already answer either field
/// (`Palette.nsColor(for:)`), so a combo's *role* is only about which twelve it lists, never about
/// which values it can resolve.
enum ColorComboRole: Sendable, Equatable {
case background
case foreground
/// The twelve rows this picker offers.
var palette: [PaletteColor] {
switch self {
case .background: Palette.backgrounds
case .foreground: Palette.foregrounds
}
}
/// The *other* picker's twelve consulted only to name a foreign palette value in the dynamic
/// current-value row (`ColorComboModel.match`). Never offered as a row of this picker's own,
/// which is what keeps "background lists backgrounds" true even though `Palette.nsColor(for:)`
/// itself would happily resolve a foreground name.
var otherPalette: [PaletteColor] {
switch self {
case .background: Palette.foregrounds
case .foreground: Palette.backgrounds
}
}
}
// MARK: - Rows and matching
/// One row of a `ColorComboView`'s dropdown, in display order.
enum ColorComboItem: Equatable {
/// Clears the field the well grid's own leading None well, same removal.
case none
case separator
/// One of `role`'s own twelve, by name. `ColorComboModel.displayName(_:)` is its title; the row
/// is never built with anything the role's own palette doesn't list.
case palette(String)
/// The live value's own row present only when the stored value matches neither `.none` nor a
/// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a
/// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is
/// the display text `ColorComboModel.match` already worked out.
case current(swatchValue: String, title: String)
/// Opens `NSColorPanel.shared`.
case other
}
/// Which row a stored value checks computed once and shared by the item list (`ColorComboModel.
/// menu`) and by anything that just wants to know "what does this resolve to" without building
/// rows, which is most of what a test wants to assert.
enum ColorComboMatch: Equatable {
case none
case palette(String)
case current(swatchValue: String, title: String)
}
/// The full dropdown for one role at one value: its rows, and the index of the checked one.
struct ColorComboMenu: Equatable {
let items: [ColorComboItem]
/// Always a valid index into `items` the None row exists in every menu, so there is always at
/// least one candidate to fall back to.
let selectedIndex: Int
}
// MARK: - The pure model
/// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value
/// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still.
enum ColorComboModel {
// MARK: Display
/// Kebab-case palette name Title Case with hyphens as spaces: `"light-cayenne"`
/// `"Light Cayenne"`, `"smokey-rich-eggplant"` `"Smokey Rich Eggplant"` the one place a
/// palette name becomes a row's title rather than its stored spelling.
static func displayName(_ name: String) -> String {
name.split(separator: "-")
.map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
}
// MARK: Matching
/// Which row `value` checks, given `role`:
/// - `nil` `.none`.
/// - a name in `role`'s own palette `.palette(name)`, matched exactly `Palette`'s own
/// case-sensitive rule, unchanged here.
/// - a hex that, normalized, equals one of `role`'s palette hexes that colour's `.palette`
/// match, **by name** a panel pick landing exactly on a palette colour selects the name, so
/// picking it again from the panel later re-emits the name rather than drifting to a hex.
/// - anything else (a foreign palette name, a custom hex, or unresolvable garbage) `.current`,
/// titled with the other picker's display name when `value` is one of *its* twelve, else
/// `value` itself, uppercased when it looks like hex and left verbatim otherwise.
static func match(role: ColorComboRole, value: String?) -> ColorComboMatch {
guard let value else { return .none }
if role.palette.contains(where: { $0.name == value }) {
return .palette(value)
}
if let normalized = normalizedHex(value),
let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) {
return .palette(hit.name)
}
return .current(swatchValue: value, title: currentTitle(role: role, value: value))
}
/// The dynamic current-value row's title the other table's display name when `value` is one
/// of its twelve, the raw string otherwise (hex shown uppercase, matching `NSColor.
/// paletteHexString`'s own casing so a stored value and a freshly panel-picked one read alike).
private static func currentTitle(role: ColorComboRole, value: String) -> String {
if let foreign = role.otherPalette.first(where: { $0.name == value }) {
return displayName(foreign.name)
}
return value.hasPrefix("#") ? value.uppercased() : value
}
// MARK: Item list
/// The dropdown's full row list and which row is checked, for `role` at `value`: **None**,
/// separator, the twelve, then only when `match` lands on `.current` that dynamic row,
/// separator, **Other**.
static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu {
var items: [ColorComboItem] = [.none, .separator]
items.append(contentsOf: role.palette.map { .palette($0.name) })
let selectedIndex: Int
switch match(role: role, value: value) {
case .none:
selectedIndex = 0
case let .palette(name):
selectedIndex = items.firstIndex(of: .palette(name)) ?? 0
case let .current(swatchValue, title):
items.append(.current(swatchValue: swatchValue, title: title))
selectedIndex = items.count - 1
}
items.append(.separator)
items.append(.other)
return ColorComboMenu(items: items, selectedIndex: selectedIndex)
}
// MARK: Hex normalization
/// `#RRGGBB`/`#RRGGBBAA` uppercase, alpha-`FF` collapsed to six digits the string-side half
/// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for
/// **comparison**: two spellings of the same opaque colour normalize to the same string, so a
/// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would.
/// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value
/// never accidentally matches a palette colour by coincidence.
static func normalizedHex(_ value: String) -> String? {
var upper = value.uppercased()
guard upper.hasPrefix("#") else { return nil }
let digits = upper.dropFirst()
guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil }
if digits.count == 8, digits.hasSuffix("FF") {
upper.removeLast(2)
}
return upper
}
}
// MARK: - View
/// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is
/// `ColorComboModel.menu(role:value:)` built exactly as it always was, just handed to the control
/// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one
/// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one
/// hit zone for exactly one action.
struct ColorComboView: NSViewRepresentable {
let role: ColorComboRole
/// The raw stored value a palette name or a hand-written hex, exactly as the frontmatter field
/// carries it. Never a resolved `Color`: matching needs the string, not what it renders as.
let value: String?
let isEnabled: Bool
/// One discrete row picked **None**, one of the twelve, or the dynamic current-value row.
/// Fired once, synchronously; the call site commits it immediately.
var onChange: @MainActor (String?) -> Void
/// One tick of a live `NSColorPanel` drag opened from **Other** or the swatch zone fires
/// repeatedly while the user is still adjusting the colour. Kept separate from `onChange`
/// because the two halves of this control's contract differ at the call site
/// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to
/// debounce and never feeds style recents.
var onPanelChange: @MainActor (String?) -> Void
/// The width `sizeThatFits` hands back when SwiftUI has no concrete proposal to fill an
/// unconstrained measuring pass, not the normal case. The normal case is a finite proposal (this
/// view sits under `.frame(maxWidth: .infinity)` in the sidebar row, `CardActionsSection`'s
/// Delete button's own trick), which this default never has to stand in for.
private static let defaultFaceWidth: CGFloat = 120
func makeNSView(context: Context) -> ColorComboControl {
let control = ColorComboControl(frame: .zero)
// The swatch zone's one job: open the same panel takeover **Other** does. A closure, not a
// target/action pair there is exactly one caller and no `NSMenuItem`-style Objective-C
// boundary to cross for it.
control.onSwatchClick = { [weak coordinator = context.coordinator] in
coordinator?.openColorPanel()
}
return control
}
func updateNSView(_ control: ColorComboControl, context: Context) {
context.coordinator.role = role
context.coordinator.onChange = onChange
context.coordinator.onPanelChange = onPanelChange
control.isEnabled = isEnabled
context.coordinator.rebuild(control, value: value)
}
/// Obeys whatever width SwiftUI proposes, like any other control never the widest menu item,
/// which is what the old caller-supplied `width` input existed to work around. Height is the
/// control's own fitting height (`ColorComboControl.intrinsicContentSize`, about half the old
/// regular `NSPopUpButton`'s the whole point of this rework); width is the proposal's when it
/// is an actual number, else `defaultFaceWidth`, since a `nil`/infinite proposal happens on an
/// unconstrained measuring pass, not a sidebar row.
func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? {
let width: CGFloat
if let proposed = proposal.width, proposed.isFinite {
width = proposed
} else {
width = Self.defaultFaceWidth
}
return CGSize(width: width, height: nsView.intrinsicContentSize.height)
}
/// Detaches the colour panel's target/action if this coordinator still holds them "last-writer
/// wins" for any control that took the panel over afterward (this view's own doc comment).
static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) {
coordinator.detachColorPanel()
}
func makeCoordinator() -> Coordinator {
Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange)
}
// MARK: Coordinator
/// The one object every menu action and the colour panel's action target a class because
/// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later,
/// and `@MainActor` because every AppKit call it makes has to be.
@MainActor
final class Coordinator: NSObject {
fileprivate var role: ColorComboRole
fileprivate var currentValue: String?
fileprivate var onChange: @MainActor (String?) -> Void
fileprivate var onPanelChange: @MainActor (String?) -> Void
/// ~44×14pt a menu row's swatch, wide enough beside its title to read as a colour sample
/// rather than a bullet.
private static let menuSwatchSize = NSSize(width: 44, height: 14)
/// Whichever coordinator most recently took the shared panel over `NSColorPanel` exposes
/// `setTarget(_:)`/`setAction(_:)` but no matching getter, so "is it still mine to detach"
/// has nowhere to live but here. `weak`, so a coordinator that never got around to detaching
/// (a window closed from under it) does not keep the next owner from being collected either.
private static weak var currentPanelOwner: Coordinator?
init(
role: ColorComboRole,
onChange: @escaping @MainActor (String?) -> Void,
onPanelChange: @escaping @MainActor (String?) -> Void
) {
self.role = role
self.onChange = onChange
self.onPanelChange = onPanelChange
}
/// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the
/// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its
/// accessibility value), and the value its swatch zone should draw. Cheap enough a dozen
/// rows, a fistful of small menu-row images to redo wholesale on every SwiftUI update
/// rather than diffing against what was there before.
func rebuild(_ control: ColorComboControl, value: String?) {
currentValue = value
let menu = NSMenu()
let built = ColorComboModel.menu(role: role, value: value)
var checkedItem: NSMenuItem?
for (index, item) in built.items.enumerated() {
if item == .separator {
menu.addItem(.separator())
continue
}
let menuItem = self.menuItem(for: item)
let isChecked = index == built.selectedIndex
menuItem.state = isChecked ? .on : .off
menu.addItem(menuItem)
if isChecked { checkedItem = menuItem }
}
control.comboMenu = menu
control.checkedItem = checkedItem
control.swatchValue = value
}
/// See `ColorComboView.dismantleNSView(_:coordinator:)`.
func detachColorPanel() {
guard Coordinator.currentPanelOwner === self else { return }
let panel = NSColorPanel.shared
panel.setTarget(nil)
panel.setAction(nil)
Coordinator.currentPanelOwner = nil
}
private func menuItem(for item: ColorComboItem) -> NSMenuItem {
switch item {
case .none:
let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "")
menuItem.target = self
menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize)
return menuItem
case let .palette(name):
let menuItem = NSMenuItem(
title: ColorComboModel.displayName(name),
action: #selector(selectValue(_:)),
keyEquivalent: ""
)
menuItem.target = self
menuItem.representedObject = name
menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize)
return menuItem
case let .current(swatchValue, title):
let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "")
menuItem.target = self
menuItem.representedObject = swatchValue
menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize)
return menuItem
case .other:
let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "")
menuItem.target = self
return menuItem
case .separator:
// Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch
// stays total against a case list a future row could still grow.
return NSMenuItem.separator()
}
}
@objc private func selectNone() {
onChange(nil)
}
@objc private func selectValue(_ sender: NSMenuItem) {
onChange(sender.representedObject as? String)
}
/// Seeds the shared panel with the current resolved colour (black when there isn't one),
/// takes it over "don't fight over the panel if something else takes it later" (this
/// view's own doc comment) and asks for continuous updates, which is what makes a drag on
/// the panel's own sliders call `changeColor(_:)` on every tick rather than only on release.
///
/// Two callers, one takeover: the dropdown's own **Other** row (`#selector` target above)
/// and `ColorComboControl`'s swatch-zone click (wired in `ColorComboView.makeNSView`)
/// Xcode's own two-zone combo opens the same panel from either half, and this is the one
/// place that happens.
@objc func openColorPanel() {
let panel = NSColorPanel.shared
panel.showsAlpha = true
panel.color = currentValue.flatMap(Palette.nsColor(for:)) ?? .black
panel.setTarget(self)
panel.setAction(#selector(changeColor(_:)))
Coordinator.currentPanelOwner = self
panel.makeKeyAndOrderFront(nil)
}
/// The panel's own action, continuous while the user drags: normalizes what it picked to
/// this app's stored-value vocabulary and hands it to `onPanelChange` the palette name
/// when the colour lands exactly on one of `role`'s twelve, the hex otherwise. The name-wins
/// rule is the same one `ColorComboModel.match` applies to a value already on disk.
@objc private func changeColor(_ sender: NSColorPanel) {
guard let hex = sender.color.paletteHexString else { return }
onPanelChange(Palette.name(forHex: hex, in: role.palette) ?? hex)
}
}
}
// MARK: - Two-zone NSControl
/// The collapsed face: a custom control in Xcode's inspector colour combo's own shape a flat
/// swatch filling almost the whole control, and a fixed-width chevron trigger at the trailing edge.
/// The swatch zone opens the Colors panel directly (`ColorComboView.Coordinator.openColorPanel()`);
/// the trigger zone pops the dropdown `ColorComboView.Coordinator.rebuild(_:value:)` builds. Neither
/// zone owns a bezel or a cell of its own everything both draw and hit-test is computed straight
/// from `bounds` on every pass, so there is nothing cached here the way the face image the
/// `NSPopUpButton` this replaces used to keep (`swatchValue`'s `didSet` just marks a redraw).
///
/// Plain internal, not `private`/`fileprivate`, even though nothing outside this file constructs one
/// directly: it is `ColorComboView`'s `NSViewType`, an associated-type witness the compiler requires
/// to be at least as visible as `ColorComboView` itself (internal, usable module-wide) same
/// reasoning as the un-modified-access `Coordinator` a few lines up.
final class ColorComboControl: NSControl {
/// The value the swatch zone currently draws a palette name or a hand-written hex, exactly as
/// `ColorComboView.Coordinator.rebuild(_:value:)` hands it over on every SwiftUI update.
var swatchValue: String? {
didSet {
guard swatchValue != oldValue else { return }
needsDisplay = true
}
}
/// The dropdown the trigger zone pops, and the row within it that should read as checked both
/// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem`
/// is always one of `comboMenu`'s own items). This control never builds a row itself; it only
/// positions and pops what it is given.
var comboMenu: NSMenu?
var checkedItem: NSMenuItem?
/// Fired by a click anywhere in the swatch zone wired once, in `ColorComboView.makeNSView`, to
/// the coordinator's `openColorPanel()`. `@MainActor`, this file's own established convention for
/// a stored closure an AppKit callback fires (`onChange`/`onPanelChange` above), even though this
/// control's own methods are already implicitly MainActor-isolated as an `NSResponder` subclass.
var onSwatchClick: (@MainActor () -> Void)?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
/// About half the old regular `NSPopUpButton`'s height the whole point of this rework. Width
/// is `NSView.noIntrinsicMetric`: this control obeys whatever SwiftUI proposes, exactly as the
/// button it replaces did.
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: 14)
}
/// The fixed-width trigger strip at the trailing edge, full height the geometry this whole
/// control exists to draw: "a flat swatch occupying the control, a chevron trigger at the
/// trailing edge."
private static let triggerWidth: CGFloat = 16
/// The swatch zone's padding before its rounded rect two points rather than a bare hairline,
/// so the control's own field (`drawField()`) reads as a visible ring around the colour instead
/// of being covered by it.
private static let swatchPadding: CGFloat = 2
private static let cornerRadius: CGFloat = 3
/// The field's own radius a point more than the swatch's, so the two rounded rects run
/// concentric instead of pinching at the corners.
private static let fieldRadius: CGFloat = 4
private var triggerRect: NSRect {
NSRect(x: bounds.maxX - Self.triggerWidth, y: bounds.minY, width: Self.triggerWidth, height: bounds.height)
}
private var swatchZone: NSRect {
NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width - Self.triggerWidth, height: bounds.height)
}
// MARK: Drawing
override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
defer { context.restoreGState() }
// A transparency layer, not a flat `setAlpha` around each shape: the swatch's underlay,
// fill and stroke overlap, and drawing each at reduced alpha independently would let the
// stroke double up over the fill beneath it. Compositing the whole disabled face as one
// layer avoids that.
if !isEnabled {
context.setAlpha(0.35)
context.beginTransparencyLayer(auxiliaryInfo: nil)
}
drawField()
drawSwatch()
drawTrigger()
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
/// zones what makes the swatch and the trigger read as one control rather than two shapes
/// floating beside each other. Standard control materials: `controlBackgroundColor` fill,
/// `separatorColor` hairline, the half-point inset keeping the stroke on whole pixels.
private func drawField() {
let path = NSBezierPath(
roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5),
xRadius: Self.fieldRadius,
yRadius: Self.fieldRadius
)
NSColor.controlBackgroundColor.setFill()
path.fill()
NSColor.separatorColor.setStroke()
path.lineWidth = 1
path.stroke()
}
/// The colour rect, drawn exactly like `PaletteSwatch.rectImage`: 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.
private func drawSwatch() {
let inset = swatchZone.insetBy(dx: Self.swatchPadding, dy: Self.swatchPadding)
let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.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()
}
/// The trigger: a small vertically-centred rounded **square**, `controlAccentColor`-filled, with
/// a white `chevron.up.chevron.down` centred inside the standard `NSPopUpButton` indicator's
/// own look, redrawn here since this control has no bezel of its own to borrow one from.
private func drawTrigger() {
let side = triggerRect.height - 2 * Self.swatchPadding
let square = NSRect(
x: triggerRect.midX - side / 2,
y: triggerRect.midY - side / 2,
width: side,
height: side
)
let path = NSBezierPath(roundedRect: square, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
NSColor.controlAccentColor.setFill()
path.fill()
let config = NSImage.SymbolConfiguration(pointSize: 7, weight: .bold)
.applying(.init(paletteColors: [.white]))
guard let chevron = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: nil)?
.withSymbolConfiguration(config)
else { return }
let size = chevron.size
chevron.draw(in: NSRect(
x: square.midX - size.width / 2,
y: square.midY - size.height / 2,
width: size.width,
height: size.height
))
}
// MARK: Events
/// Point-in-trigger-zone pops the dropdown; anywhere else in the control fires the swatch click
/// the two-zone split this whole rework exists for. A disabled control answers neither.
override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
let point = convert(event.locationInWindow, from: nil)
if triggerRect.contains(point) {
popUpMenu()
} else {
onSwatchClick?()
}
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return pop the dropdown the one keyboard path into this control. There is
/// currently no keyboard equivalent for the swatch zone's direct panel launch; see this class's
/// own doc comment and the file's top-level report for what a full accessibility pass would add.
override func keyDown(with event: NSEvent) {
guard isEnabled else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 49, 36, 76: // Space, Return, keypad Enter
popUpMenu()
default:
super.keyDown(with: event)
}
}
/// Standard popup placement: `comboMenu` is asked to land `checkedItem` at the control's own top
/// edge, the same non-pulldown anchor `NSPopUpButton` itself uses so the checked row appears
/// where the control's own face is rather than wherever the pointer happened to be.
private func popUpMenu() {
guard let comboMenu else { return }
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
}
// MARK: Accessibility
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
/// The checked row's own title "Light Cayenne", "None", a bare hex exactly what the dropdown
/// itself would show ticked, since `Coordinator.rebuild(_:value:)` hands this control the very
/// item it built the menu from rather than a copy.
override func accessibilityValue() -> Any? { checkedItem?.title }
}