The owner's evening review superseded the same day's 4:5-ratio pass: 'reduce vertical size of both pickers by 50%, on symbol picker by reducing padding.' ComboFieldMetrics.height goes back to 1.4 em (18pt at the standard body, exactly half the 4:5 pass's 36) — both combos get this for free, since it's shared. width stops being height's own restatement (widthToHeightRatio is gone); it holds the 4:5 pass's pixel figure as its own em number instead, because re-deriving it at the new height would starve the trigger strip and empty the face zone entirely. SymbolComboControl.drawFace now sizes its glyph off the actual face rect it is handed at draw time rather than the pure ComboFieldMetrics.glyphPointSize (removed, along with its doc — it only ever described the control's intrinsic portrait width, not the stretched-wide shape the card sidebar's row actually proposes). No inset — SF Symbols carry their own margins — with the sizing and overshoot-scaling rules split into two pure static functions so the fit is assertable without a live draw. ComboFieldMetricsTests updated for the new figures and the new actual-rect glyph rule; the superseded 4:5-ratio-at-six-sizes test is replaced by one pinning height/width independently and asserting faceWidth stays positive at every size the old test used. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
330 lines
16 KiB
Swift
330 lines
16 KiB
Swift
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`.
|
||
///
|
||
/// ### The 2026-08-09 iterations: no padding, a taller narrower field, a centred glyph — then half
|
||
/// the height back off again
|
||
///
|
||
/// The owner's first review of the shape above: "remove padding from the combo. make the field
|
||
/// taller and narrower. almost square. about 4:5 ratio. for symbol picker center the symbol." Three
|
||
/// changes, all in this file —
|
||
///
|
||
/// - **No padding.** `facePaddingH`/`facePaddingV`/`glyphPadding` are gone; a face draws to fill its
|
||
/// whole zone, not a rect inset inside it. A swatch is now a solid patch flush with the field's own
|
||
/// edges rather than a bar floating in a ring of `controlColor`.
|
||
/// - **`width` joined `height` as a real figure**, replacing `NSView.noIntrinsicMetric`. The first
|
||
/// pass let every caller stretch the control to whatever width it had lying around — 93pt in the
|
||
/// card sidebar, 120pt's fallback in the board popover — which is the wide-bar shape this iteration
|
||
/// undoes.
|
||
/// - **The glyph centres in both axes.** It always claimed to (`SymbolComboControl.drawFace`'s own
|
||
/// doc comment said "centred") but the code only ever centred it vertically and offset it from the
|
||
/// leading edge by the swatch's own horizontal padding — dead code once that padding left, and the
|
||
/// wrong rect even before it did.
|
||
///
|
||
/// That pass reached "almost square" by growing `height` to 4:5 against a `width` that was purely
|
||
/// `height`'s own restatement (`widthToHeightRatio`). The owner's evening review of the result: "reduce
|
||
/// vertical size of both pickers by 50%, on symbol picker by reducing padding" — the taller field read
|
||
/// as too tall once it was on screen, superseding the 4:5 ruling outright rather than adjusting it.
|
||
/// `height` is cut in half here (back to the very figure the first pass shipped, before "almost square"
|
||
/// ever entered the conversation). `width` had to stop being a function of `height` to survive that cut:
|
||
/// restating the old 0.8 ratio at the new height would ask for a 14pt-wide field, narrower than the
|
||
/// 16pt trigger strip alone, which drives `faceWidth` negative-then-clamped-to-zero and empties the
|
||
/// board popover anchor that sizes itself from this control's intrinsic width. So `width` is now its
|
||
/// own em figure, holding the *pixel width* the 4:5 pass shipped rather than any ratio to `height`.
|
||
///
|
||
/// **The net shape is a short wide bar again, not the 4:5 pass's portrait** — 18×29 at the standard
|
||
/// body, width now the larger of the two figures. That is closer to the very first pass's spirit
|
||
/// (a field wider than tall) than to "almost square", which is the point: the evening review is a
|
||
/// reversal of the 4:5 ruling, not a smaller step along the same road.
|
||
struct ComboFieldMetrics: Equatable {
|
||
|
||
/// The control's height.
|
||
///
|
||
/// **1.4 em is 18pt at the standard body — exactly half of the 4:5 pass's 36**, and incidentally
|
||
/// the very figure the original pass before that one shipped. The owner's evening review asked for
|
||
/// "reduce vertical size of both pickers by 50%" outright, not a re-tuning against the trigger or
|
||
/// the glyph; halving the shipped 36 is that request applied literally.
|
||
var height: CGFloat
|
||
/// The whole control's width — its own em figure now, not a scaling of `height`. **2.2 em is 29pt
|
||
/// at the standard body, the exact pixel width the 4:5 pass shipped** (`36 × 0.8`, rounded) — this
|
||
/// iteration holds that width steady rather than re-deriving it from the now-shorter height, since
|
||
/// a re-derivation would starve the trigger strip and empty the face zone (this type's own header).
|
||
/// Every caller still gets this figure unless it hands SwiftUI an explicit finite proposal of its
|
||
/// own, which `sizeThatFits` on each representable still honours exactly as before.
|
||
var width: CGFloat
|
||
/// The trigger strip at the trailing edge, full height. Unchanged from the first pass: this
|
||
/// iteration narrows the *field*, not the door onto the quick list, and 1.25 em already gave the
|
||
/// trigger square room for a legible chevron.
|
||
var triggerWidth: CGFloat
|
||
/// The trigger square's inset from the strip's height — kept off the *ring* width rather than
|
||
/// a face padding that no longer exists, so the indicator stays a legible square rather than
|
||
/// growing to fill whichever of the strip's two dimensions is smaller (`ComboFieldControl.
|
||
/// drawTrigger`'s `min(width, height)`, which is what keeps this inset meaningful regardless of
|
||
/// which dimension ends up binding at a given body size).
|
||
var triggerInset: 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),
|
||
width: em(2.2),
|
||
triggerWidth: em(1.25),
|
||
triggerInset: 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 face zone's own width — the whole field less the trigger strip. Computed here rather than
|
||
/// read off a live `NSView`'s `faceZone` so it is a plain function of `bodyPointSize`, available
|
||
/// to a pure test with no control on screen. Kept positive by construction now that `width` is its
|
||
/// own em figure rather than a ratio of `height` (this type's own header): the evening 2026-08-09
|
||
/// cut halved `height` alone, and a `width` still deriving from it would have driven this to zero.
|
||
var faceWidth: CGFloat { max(0, width - triggerWidth) }
|
||
}
|
||
|
||
// MARK: - Glyph sizing moved to the actual face rect
|
||
|
||
// `ComboFieldMetrics` no longer carries a `glyphPointSize`. It used to (`max(1, min(faceWidth,
|
||
// height))`), a pure figure derived from the *intrinsic* portrait size — correct only when a control
|
||
// actually renders at its intrinsic width, which the card sidebar's row never lets it do (`sizeThatFits`
|
||
// honours the row's real width proposal). `SymbolComboControl.drawFace` now sizes its glyph off the
|
||
// `rect` it is actually handed, which is this type's whole reason to still track `faceWidth`: a pure
|
||
// test can assert that figure stays positive without a control on screen, even though nothing draws
|
||
// from it directly anymore.
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
/// Both dimensions are the metrics' own now. Width stopped being `NSView.noIntrinsicMetric` in
|
||
/// the 2026-08-09 iteration that gave the field a fixed shape instead of whatever a caller's frame
|
||
/// happened to propose; a representable's `sizeThatFits` still honours an explicit finite proposal
|
||
/// over this default, exactly as before.
|
||
override var intrinsicContentSize: NSSize {
|
||
NSSize(width: metrics.width, 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 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.
|
||
///
|
||
/// **The side is bounded by both of the strip's own dimensions**, not just its height: the first
|
||
/// pass only took `triggerRect.height` because the strip was never far from square, but the
|
||
/// 2026-08-09 iterations moved `height` and `width` independently of one another (this file's
|
||
/// header), and a side taken from height alone would ask for a square wider than the strip itself
|
||
/// on whichever body size that stopped holding.
|
||
private func drawTrigger() {
|
||
let side = min(triggerRect.width, 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
|
||
}
|
||
}
|