The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover

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
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent ca0328be2e
commit ece33bbf78
17 changed files with 2030 additions and 322 deletions
+50 -215
View File
@@ -4,8 +4,8 @@ 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
/// `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()`).
///
@@ -27,13 +27,14 @@ import SwiftUI
/// 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
/// (`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 twelve rows this picker offers.
/// 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
@@ -41,7 +42,7 @@ enum ColorComboRole: Sendable, Equatable {
}
}
/// The *other* picker's twelve consulted only to name a foreign palette value in the dynamic
/// 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.
@@ -60,7 +61,7 @@ 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
/// 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
@@ -116,7 +117,7 @@ enum ColorComboModel {
/// 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
/// 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 }
@@ -131,7 +132,7 @@ enum ColorComboModel {
}
/// 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.
/// 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 }) {
@@ -143,7 +144,7 @@ enum ColorComboModel {
// 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, 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]
@@ -199,7 +200,7 @@ struct ColorComboView: NSViewRepresentable {
/// 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.
/// 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
@@ -217,12 +218,16 @@ struct ColorComboView: NSViewRepresentable {
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
// 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
}
@@ -230,6 +235,7 @@ struct ColorComboView: NSViewRepresentable {
context.coordinator.role = role
context.coordinator.onChange = onChange
context.coordinator.onPanelChange = onPanelChange
control.metrics = .current
control.isEnabled = isEnabled
context.coordinator.rebuild(control, value: value)
}
@@ -276,11 +282,10 @@ struct ColorComboView: NSViewRepresentable {
/// 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?
/// 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,
@@ -315,16 +320,13 @@ struct ColorComboView: NSViewRepresentable {
}
control.comboMenu = menu
control.checkedItem = checkedItem
control.accessibilityValueText = checkedItem?.title
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
colorPanel.detach()
}
private func menuItem(for item: ColorComboItem) -> NSMenuItem {
@@ -373,51 +375,40 @@ struct ColorComboView: NSViewRepresentable {
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.
/// 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 swatch-zone click (wired in `ColorComboView.makeNSView`)
/// 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() {
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)
colorPanel.present(
seed: currentValue.flatMap(Palette.nsColor(for:)),
matching: role.palette
) { [weak self] value in
self?.onPanelChange(value)
}
}
}
}
// MARK: - Two-zone NSControl
// MARK: - The control
/// 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).
/// 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: NSControl {
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.
@@ -435,100 +426,14 @@ final class ColorComboControl: NSControl {
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's padding inside its zone, asymmetric and user-tuned: a wider berth at the sides
/// than above and below, so the colour reads as a bar sitting in the field rather than filling
/// it wall to wall. The space comes out of the swatch the control's overall size is untouched.
private static let swatchPaddingH: CGFloat = 7
private static let swatchPaddingV: CGFloat = 4
/// The trigger square's own inset from the zone's height kept at the old ring width rather
/// than the swatch's larger padding, so the indicator stays a legible ~10pt square instead of
/// shrinking with every padding tweak the swatch takes.
private static let triggerInset: 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. `controlColor` fill the push-button neutral grey, not
/// `controlBackgroundColor`, whose near-black dark-mode reading drowned the padding ring
/// `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.controlColor.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.swatchPaddingH, dy: Self.swatchPaddingV)
let path = NSBezierPath(roundedRect: inset, xRadius: Self.cornerRadius, yRadius: Self.cornerRadius)
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) {
@@ -540,81 +445,11 @@ final class ColorComboControl: NSControl {
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.triggerInset
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() {
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 }
}