The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
456 lines
22 KiB
Swift
456 lines
22 KiB
Swift
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 own
|
||
/// 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 table it lists, never about
|
||
/// which values it can resolve.
|
||
enum ColorComboRole: Sendable, Equatable {
|
||
case background
|
||
case foreground
|
||
|
||
/// The rows this picker offers — one per entry in the role's own table (sixteen since
|
||
/// 2026-08-09; the count is the palette's business, not this type's).
|
||
var palette: [PaletteColor] {
|
||
switch self {
|
||
case .background: Palette.backgrounds
|
||
case .foreground: Palette.foregrounds
|
||
}
|
||
}
|
||
|
||
/// The *other* picker's table — 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 entries, 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* own, 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 own, 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 role's own entries, 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 palette rows, 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 two zones' jobs, wired once (`ComboFieldControl`'s grammar): the face opens the same
|
||
// panel takeover **Other…** does, the trigger pops the dropdown. Closures, not target/action
|
||
// pairs — there is exactly one caller each and no `NSMenuItem`-style Objective-C boundary to
|
||
// cross for them.
|
||
control.onFaceClick = { [weak coordinator = context.coordinator] in
|
||
coordinator?.openColorPanel()
|
||
}
|
||
control.onTriggerClick = { [weak control] in
|
||
control?.popUpMenu()
|
||
}
|
||
return control
|
||
}
|
||
|
||
func updateNSView(_ control: ColorComboControl, context: Context) {
|
||
context.coordinator.role = role
|
||
context.coordinator.onChange = onChange
|
||
context.coordinator.onPanelChange = onPanelChange
|
||
control.metrics = .current
|
||
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)
|
||
|
||
/// This coordinator's handle on `NSColorPanel.shared` — the takeover, its continuous action
|
||
/// and its last-writer-wins detach all live in `SystemColorPanel` now, shared with the two
|
||
/// surfaces that gained an **Other…** on 2026-08-09.
|
||
private let colorPanel = SystemColorPanel()
|
||
|
||
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.accessibilityValueText = checkedItem?.title
|
||
control.swatchValue = value
|
||
}
|
||
|
||
/// See `ColorComboView.dismantleNSView(_:coordinator:)`.
|
||
func detachColorPanel() {
|
||
colorPanel.detach()
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
/// Opens the shared Colors panel seeded with the current resolved colour, streaming what it
|
||
/// picks into `onPanelChange` — the palette name when the colour lands exactly on one of
|
||
/// `role`'s own entries, the hex otherwise. Every rule of that takeover is
|
||
/// `SystemColorPanel`'s; this is the seed and the destination.
|
||
///
|
||
/// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above)
|
||
/// and `ColorComboControl`'s face-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() {
|
||
colorPanel.present(
|
||
seed: currentValue.flatMap(Palette.nsColor(for:)),
|
||
matching: role.palette
|
||
) { [weak self] value in
|
||
self?.onPanelChange(value)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - The control
|
||
|
||
/// The collapsed face: `ComboFieldControl` with a **swatch** in its face zone.
|
||
///
|
||
/// Everything that makes it a two-zone combo — the field, the hairline, the trailing chevron square,
|
||
/// the hit split, the disabled compositing, the geometry — is the base class's now (ComboField.swift),
|
||
/// shared with the symbol combo so the two cannot drift apart. What is left here is the one thing that
|
||
/// is actually about *colour*: the swatch, and the menu the trigger pops.
|
||
///
|
||
/// 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: ComboFieldControl {
|
||
|
||
/// 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?
|
||
|
||
/// 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.
|
||
override func drawFace(in rect: NSRect) {
|
||
let inset = rect.insetBy(dx: metrics.facePaddingH, dy: metrics.facePaddingV)
|
||
guard inset.width > 0, inset.height > 0 else { return }
|
||
let path = NSBezierPath(roundedRect: inset, 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()
|
||
}
|
||
|
||
/// 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.
|
||
func popUpMenu() {
|
||
guard let comboMenu else { return }
|
||
comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self)
|
||
}
|
||
}
|