import AppKit /// **The shared single-zone rectangle** — the chrome behind both pickers (`SymbolGlyphControl` in /// SymbolPicker.swift, `ColorSwatchControl` in ColorSwatchPicker.swift): a bordered, filled rounded /// rect with exactly **one** hit zone, the whole of it. A click anywhere opens the control's popover. /// /// ### The owner's reversal (2026-08-10, Pipeline card 5004c540, verbatim) /// /// "lets revert to previous look of the symbol picker, no combo trigger. just a rectangle (slightly /// oversized) clickable to show a popover with grid of symbols and colors (same popover that shows up /// on click of combo trigger). lets do same for color..." /// /// This retires the two-zone combo chrome this file used to hold (`ComboFieldControl`/ /// `ComboFieldMetrics`, the 2026-08-09 "the two pickers should rhyme" pass): the face/trigger split, /// the `NSPopUpButton`-style chevron square at the trailing edge, and the face zone's own door onto a /// *standalone* picker independent of the popover. What survives, because it was never about the /// trigger: the bordered rounded-rect field (`drawField`), the disabled treatment (a single /// transparency layer under both), and — because a popover is still the one way into a standalone /// picker (`SymbolBrowserPanel`'s **More Symbols…** row, the new colour popover's **Other…** row) — /// the `.popUpButton` accessibility role and the value text a coordinator hands over. /// /// ### Sizing (the same ruling): "50% taller... and about 4:6 ratio of height to width" /// /// `height` is **2.1 em** — 50% over the two-zone chrome's last-shipped 1.4 em (18pt → 27pt at the /// standard 13pt body). `width` is `height` **times a per-role ratio**, stated as a multiplier on /// `height` rather than as its own independent em figure, so each ratio holds exactly (to rounding) at /// every body size instead of two multiples that could drift apart the way `ComboFieldMetrics`' /// `height` and `width` once did (that file's own retired header told that story). 10-accessibility.md's /// full-relative-scaling rule, unchanged: every figure is a multiple of the body point size. /// /// ### Two widths, one height (the owner's 2026-08-10 follow-up, verbatim) /// /// "width to height ratio should be closer to 6:4 [for color] ... the symbol picker should be even /// more square at ratio of 5:4 or so ... the height for the two pickers should be exactly the same." /// `height` stays a single shared figure — nothing here lets it diverge between the two controls — /// while `width` becomes a function of which picker is asking, via `WidthRatio`. The ratio lives as a /// multiplier rather than two hand-rounded em widths for the reason above: two independent em figures /// can drift apart a rounding step at a time as the body size changes, the exact failure this file's /// own `height`/`width` split was already written to avoid for the first ratio. struct PickerRectMetrics: Equatable { /// The control's height — 2.1 em, 27pt at the standard 13pt body. **Identical between the two /// pickers by the owner's own ruling**; nothing in this type lets a caller diverge it by role. var height: CGFloat /// The control's width — `height × widthRatio`, so the requested ratio is exact rather than /// approximated by two independently rounded em figures. var width: CGFloat /// The rectangle's own radius, and the field's — a point apart so the two rounded rects run /// concentric. Unchanged from the two-zone chrome's own figures; nothing about the trigger's /// removal touches these. var cornerRadius: CGFloat var fieldRadius: CGFloat /// The width:height multiplier a picker asks for — a closed set of two named cases (not a bare /// `CGFloat` parameter) so a call site reads "the colour picker's ratio" rather than a literal /// `1.5` a reader has to trust is still current. enum WidthRatio: CGFloat, Equatable { /// The colour rectangle — "closer to 6:4" (width:height), the owner's 2026-08-10 follow-up. case color = 1.5 /// The symbol rectangle — "even more square... ratio of 5:4 or so", the same follow-up. case symbol = 1.25 } static func metrics(bodyPointSize: CGFloat, widthRatio: WidthRatio) -> PickerRectMetrics { func em(_ multiple: CGFloat) -> CGFloat { max(1, (bodyPointSize * multiple).rounded()) } let height = em(2.1) let width = max(1, (height * widthRatio.rawValue).rounded()) return PickerRectMetrics( height: height, width: width, cornerRadius: em(0.23), fieldRadius: em(0.31) ) } /// The live metrics for one picker's role — read from the same place every other font-derived /// geometry in the app reads it (`CardWindowMetrics.bodyPointSize`), so a text-size change moves /// both rectangles, and both stay the same height, with everything else. @MainActor static func current(_ widthRatio: WidthRatio) -> PickerRectMetrics { metrics(bodyPointSize: CardWindowMetrics.bodyPointSize, widthRatio: widthRatio) } } // MARK: - The control /// The shared rectangle. Subclasses draw whatever fills it; 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 PickerRectControl: 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. The placeholder ratio here is arbitrary /// — every real subclass instance gets its own role's metrics on the first `updateNSView` before /// this default is ever drawn or measured. var metrics: PickerRectMetrics = .metrics(bodyPointSize: 13, widthRatio: .color) { didSet { guard metrics != oldValue else { return } invalidateIntrinsicContentSize() needsDisplay = true } } /// The one click this control answers — anywhere in its bounds, since there is only the one zone /// now. Opens whichever popover the caller wired. /// /// Unannotated rather than `@MainActor`: this class is an `NSResponder` subclass and therefore /// already main-actor isolated, so every call site is on the main actor by construction. var onClick: (() -> Void)? /// What VoiceOver reads as this control's value: the resolved symbol's name, the swatch's stored /// value. Set by whichever coordinator owns the control. var accessibilityValueText: String? override var isEnabled: Bool { get { super.isEnabled } set { super.isEnabled = newValue needsDisplay = true } } override var intrinsicContentSize: NSSize { NSSize(width: metrics.width, height: metrics.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. if !isEnabled { context.setAlpha(0.35) context.beginTransparencyLayer(auxiliaryInfo: nil) } drawField() drawFace(in: bounds) if !isEnabled { context.endTransparencyLayer() } } /// The subclass's half: whatever fills the whole rectangle. The base draws nothing. Handed the /// control's own `bounds` — there is no zone to carve out of it any more. func drawFace(in rect: NSRect) {} /// The control's own field: a bordered, filled rounded rect over the whole bounds, under the /// face — `controlColor` fill, `separatorColor` hairline, the half-point inset keeping the /// stroke on whole pixels. Unchanged from the two-zone chrome. 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() } // MARK: Events /// Anywhere in the control, full stop — there is only the one zone now. override func mouseDown(with event: NSEvent) { guard isEnabled else { return } onClick?() } override var acceptsFirstResponder: Bool { isEnabled } /// Space and Return open the popover — the one keyboard path into this control, the same keys /// the two-zone chrome's trigger answered. 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 onClick?() default: super.keyDown(with: event) } } // MARK: Accessibility /// `.popUpButton` — unchanged from the two-zone chrome: this control still opens a set of /// choices, it just does so from one hit zone instead of two. override func accessibilityRole() -> NSAccessibility.Role? { .popUpButton } override func accessibilityValue() -> Any? { accessibilityValueText } override func accessibilityPerformPress() -> Bool { guard isEnabled else { return false } onClick?() return true } }