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 } }