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
+279
View File
@@ -0,0 +1,279 @@
import AppKit
/// **The two-zone combo, as chrome both pickers share** a bordered field with a large *face* and a
/// narrow accent-coloured *trigger* at the trailing edge, Xcode's inspector colour combo's own shape.
///
/// It exists because "the two pickers should rhyme in terms of UX" (2026-08-09) is only true as long
/// as nobody edits one of them. `ColorComboControl` drew this shape first and drew it alone; a second
/// control drawing "the same" shape from its own constants would be one careless padding tweak away
/// from two controls that merely used to match. So the shape moved here and both subclass it: the
/// colour combo's face is a swatch, the symbol combo's is a glyph, and **every other pixel is this
/// file's** width, height, radius, hairline, the trigger square, the disabled treatment, and the
/// hit split between the two zones.
///
/// ### The grammar the two zones carry
///
/// Identical on both controls, which is the whole point:
///
/// - **The face** opens the *standalone* picker `NSColorPanel.shared` for a colour
/// (`SystemColorPanel`), the symbol browser for a glyph (`SymbolBrowserPanel`). A window that
/// stays up while the user keeps choosing.
/// - **The trigger** pops the *quick* list the colour dropdown's palette rows, the symbol
/// popover's curated grid. A transient surface that closes on a pick.
///
/// Each quick list also carries a row onto the standalone picker (**Other**, **More Symbols**),
/// which is deliberate rather than redundant: the face zone is pointer-only `keyDown` below reaches
/// the trigger, and there is no second key that would obviously mean "the other zone" so the row
/// inside the popover is how the keyboard gets to the deep door at all.
///
/// ### Everything scales with the body font
///
/// 10-accessibility.md's full-relative-scaling rule, which the hard-coded constants this file
/// replaces did not follow. Every figure is a multiple of the body point size, chosen to reproduce
/// the numbers `ColorComboControl` shipped with at the standard 13pt body with one deliberate
/// exception, `height`.
// MARK: - Metrics
/// The combo's geometry, as a pure value no `NSView`, so the parity claim ("the two controls are
/// the same size") is a thing a test can assert rather than a thing a screenshot suggests.
struct ComboFieldMetrics: Equatable {
/// The control's height.
///
/// **1.4 em is 18pt at the standard body, where this shape shipped at a flat 14** the one
/// figure here that is not a restatement of an old constant. 14 was tuned for a colour bar and
/// is a fine height for one; a *glyph* in a 14pt field, once the face's own padding is taken
/// out, is six points tall and unreadable. 18 still reads as the compact control the original
/// rework was after (a regular `NSPopUpButton` is about 25) and leaves the glyph room to be a
/// glyph.
var height: CGFloat
/// The trigger strip at the trailing edge, full height.
var triggerWidth: CGFloat
/// The trigger square's inset from the strip's height kept off the *ring* width rather than
/// the face's larger padding, so the indicator stays a legible square instead of shrinking with
/// every padding tweak the face takes.
var triggerInset: CGFloat
/// The face's inset inside its zone, asymmetric and user-tuned: a wider berth at the sides than
/// above and below, so a swatch reads as a bar sitting in the field rather than filling it wall
/// to wall. The space comes out of the face the control's overall size is untouched.
var facePaddingH: CGFloat
var facePaddingV: CGFloat
/// A glyph face's own inset, much tighter than a swatch's: a symbol *is* the face, where a
/// swatch is a sample sitting in one.
var glyphPadding: CGFloat
/// The face's and the trigger square's radius.
var cornerRadius: CGFloat
/// The field's own radius a point more than the face's, so the two rounded rects run
/// concentric instead of pinching at the corners.
var fieldRadius: CGFloat
static func metrics(bodyPointSize: CGFloat) -> ComboFieldMetrics {
func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) }
return ComboFieldMetrics(
height: em(1.4),
triggerWidth: em(1.25),
triggerInset: em(0.15),
facePaddingH: em(0.54),
facePaddingV: em(0.38),
glyphPadding: em(0.15),
cornerRadius: em(0.23),
fieldRadius: em(0.31)
)
}
/// The live metrics read from the same place every other font-derived geometry in the app
/// reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves the combos with
/// everything else.
@MainActor
static var current: ComboFieldMetrics { metrics(bodyPointSize: CardWindowMetrics.bodyPointSize) }
/// The glyph's point size inside a face the face zone's height less its padding, which is
/// what makes a symbol fill the control instead of floating in it.
var glyphPointSize: CGFloat { max(1, height - 2 * glyphPadding) }
}
// MARK: - The control
/// The shared field. Subclasses draw the face and answer the two zones; nothing else here is theirs
/// to change.
///
/// Plain internal rather than `private`: both subclasses live in other files, and each is an
/// `NSViewRepresentable`'s `NSViewType`, an associated-type witness the compiler requires to be at
/// least as visible as the representable itself.
class ComboFieldControl: NSControl {
/// The geometry every pass draws from, refreshed by the representable on each SwiftUI update so
/// a text-size change lands without recreating the view.
var metrics: ComboFieldMetrics = .metrics(bodyPointSize: 13) {
didSet {
guard metrics != oldValue else { return }
invalidateIntrinsicContentSize()
needsDisplay = true
}
}
/// A click anywhere in the face zone the standalone picker's door.
///
/// Unannotated rather than `@MainActor`: this class is an `NSResponder` subclass and therefore
/// already main-actor isolated, so every call site below is on the main actor by construction
/// and the annotation would only force every caller to hand over a `@Sendable` closure it has no
/// reason to be.
var onFaceClick: (() -> Void)?
/// A click in the trigger zone, or Space/Return the quick list's door.
var onTriggerClick: (() -> Void)?
/// What VoiceOver reads as this control's value: the checked dropdown row's title, the resolved
/// symbol's name. Set by whichever coordinator owns the control.
var accessibilityValueText: String?
override var isEnabled: Bool {
get { super.isEnabled }
set {
super.isEnabled = newValue
needsDisplay = true
}
}
/// Width is `noIntrinsicMetric`: the control obeys whatever SwiftUI proposes, so a sidebar row
/// sizes it and an unconstrained pass falls back to the representable's own default.
override var intrinsicContentSize: NSSize {
NSSize(width: NSView.noIntrinsicMetric, height: metrics.height)
}
// MARK: Zones
var triggerRect: NSRect {
NSRect(
x: bounds.maxX - metrics.triggerWidth,
y: bounds.minY,
width: metrics.triggerWidth,
height: bounds.height
)
}
var faceZone: NSRect {
NSRect(
x: bounds.minX,
y: bounds.minY,
width: max(0, bounds.width - metrics.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 face'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()
drawFace(in: faceZone)
drawTrigger()
if !isEnabled {
context.endTransparencyLayer()
}
}
/// The subclass's half: whatever belongs in the face zone. The base draws nothing.
func drawFace(in rect: NSRect) {}
/// The control's own field: a bordered, filled rounded rect over the whole bounds, under both
/// zones what makes the face 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: metrics.fieldRadius,
yRadius: metrics.fieldRadius
)
NSColor.controlColor.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 * metrics.triggerInset
let square = NSRect(
x: triggerRect.midX - side / 2,
y: triggerRect.midY - side / 2,
width: side,
height: side
)
let path = NSBezierPath(roundedRect: square, xRadius: metrics.cornerRadius, yRadius: metrics.cornerRadius)
NSColor.controlAccentColor.setFill()
path.fill()
let config = NSImage.SymbolConfiguration(pointSize: (side * 0.6).rounded(), 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 quick list; anywhere else fires the face. 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) {
onTriggerClick?()
} else {
onFaceClick?()
}
}
override var acceptsFirstResponder: Bool { isEnabled }
/// Space and Return pop the quick list the one keyboard path into this control. The face zone
/// has no key of its own on purpose; the standalone picker is reached from *inside* the quick
/// list instead (this file's header).
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
onTriggerClick?()
default:
super.keyDown(with: event)
}
}
// MARK: Accessibility
override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton }
override func accessibilityValue() -> Any? { accessibilityValueText }
override func accessibilityPerformPress() -> Bool {
guard isEnabled else { return false }
onTriggerClick?()
return true
}
}