Files
lanework/Kanban/UI/ColorCombo.swift
T
rzen 9766e1f61c 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
2026-08-06 22:05:48 -04:00

615 lines
30 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
}