import AppKit 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 /// **Other…**, which hands off to that same panel — the swatch and **Other…** are two doors onto /// one panel takeover (`ColorComboView.Coordinator.openColorPanel()`). /// /// It is the second surface `background`/`iconColor` can be set from, beside the well grid /// (`StyleEditor.swift`'s `StyleEditorView`) — the well grid stays exactly as it is; this is a /// narrower, single-value control for a context where a whole grid would not fit (`CardStyleSection`'s /// own labeled row). /// /// ### Two halves, the same split every other file here draws /// /// `ColorComboRole`, `ColorComboItem`, `ColorComboMatch` and `ColorComboModel` are the **pure model** /// — item lists, selection matching, hex normalization, display-name casing — every rule a test can /// hold without an `NSView` in sight. `ColorComboView` is the thin AppKit bridge that draws it and /// answers clicks, exactly the `StyleEditorLayout`/`StyleEditorView` split in `StyleEditor.swift`. /// (Its collapsed face has its *own*, unrelated two-zone split — swatch versus trigger, /// `ColorComboControl`'s own doc comment — which has nothing to do with this pure-model/view one.) // MARK: - Role /// 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 /// which values it can resolve. enum ColorComboRole: Sendable, Equatable { case background case foreground /// The twelve rows this picker offers. var palette: [PaletteColor] { switch self { case .background: Palette.backgrounds case .foreground: Palette.foregrounds } } /// The *other* picker's twelve — 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. var otherPalette: [PaletteColor] { switch self { case .background: Palette.foregrounds case .foreground: Palette.backgrounds } } } // MARK: - Rows and matching /// One row of a `ColorComboView`'s dropdown, in display order. 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 /// 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 /// `.palette` row (`ColorComboModel.match` decides). `swatchValue` is the raw stored string a /// swatch draws from (`Palette.nsColor(for:)`, lenient exactly like `PaletteSwatch`); `title` is /// the display text `ColorComboModel.match` already worked out. case current(swatchValue: String, title: String) /// Opens `NSColorPanel.shared`. case other } /// Which row a stored value checks — computed once and shared by the item list (`ColorComboModel. /// menu`) and by anything that just wants to know "what does this resolve to" without building /// rows, which is most of what a test wants to assert. enum ColorComboMatch: Equatable { case none case palette(String) case current(swatchValue: String, title: String) } /// The full dropdown for one role at one value: its rows, and the index of the checked one. struct ColorComboMenu: Equatable { let items: [ColorComboItem] /// Always a valid index into `items` — the None row exists in every menu, so there is always at /// least one candidate to fall back to. let selectedIndex: Int } // MARK: - The pure model /// The whole of what a `ColorComboView` shows, as pure functions of `role` and a stored value — /// no `NSView`, no store, nothing a `ColorComboTests` case can't hold still. enum ColorComboModel { // MARK: Display /// Kebab-case palette name → Title Case with hyphens as spaces: `"light-cayenne"` → /// `"Light Cayenne"`, `"smokey-rich-eggplant"` → `"Smokey Rich Eggplant"` — the one place a /// palette name becomes a row's title rather than its stored spelling. static func displayName(_ name: String) -> String { name.split(separator: "-") .map { $0.isEmpty ? "" : $0.prefix(1).uppercased() + $0.dropFirst() } .joined(separator: " ") } // MARK: Matching /// Which row `value` checks, given `role`: /// - `nil` → `.none`. /// - a name in `role`'s own palette → `.palette(name)`, matched exactly — `Palette`'s own /// case-sensitive rule, unchanged here. /// - a hex that, normalized, equals one of `role`'s palette hexes → that colour's `.palette` /// 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 /// `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 } if role.palette.contains(where: { $0.name == value }) { return .palette(value) } if let normalized = normalizedHex(value), let hit = role.palette.first(where: { normalizedHex($0.hex) == normalized }) { return .palette(hit.name) } return .current(swatchValue: value, title: currentTitle(role: role, value: value)) } /// 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. /// 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 }) { return displayName(foreign.name) } return value.hasPrefix("#") ? value.uppercased() : value } // 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, **Other…**. static func menu(role: ColorComboRole, value: String?) -> ColorComboMenu { var items: [ColorComboItem] = [.none, .separator] items.append(contentsOf: role.palette.map { .palette($0.name) }) let selectedIndex: Int switch match(role: role, value: value) { case .none: selectedIndex = 0 case let .palette(name): selectedIndex = items.firstIndex(of: .palette(name)) ?? 0 case let .current(swatchValue, title): items.append(.current(swatchValue: swatchValue, title: title)) selectedIndex = items.count - 1 } items.append(.separator) items.append(.other) return ColorComboMenu(items: items, selectedIndex: selectedIndex) } // MARK: Hex normalization /// `#RRGGBB`/`#RRGGBBAA` → uppercase, alpha-`FF` collapsed to six digits — the string-side half /// of the round trip `NSColor.paletteHexString` builds (Palette.swift), used here purely for /// **comparison**: two spellings of the same opaque colour normalize to the same string, so a /// stored `#b6071eff` matches a palette entry's `#B6071E` exactly as a bare `#b6071e` would. /// `nil` for anything that isn't `#` followed by six or eight hex digits, so a malformed value /// never accidentally matches a palette colour by coincidence. static func normalizedHex(_ value: String) -> String? { var upper = value.uppercased() guard upper.hasPrefix("#") else { return nil } let digits = upper.dropFirst() guard digits.count == 6 || digits.count == 8, digits.allSatisfy(\.isHexDigit) else { return nil } if digits.count == 8, digits.hasSuffix("FF") { upper.removeLast(2) } return upper } } // MARK: - View /// The AppKit bridge: a two-zone collapsed face (`ColorComboControl`) whose dropdown is /// `ColorComboModel.menu(role:value:)` — built exactly as it always was, just handed to the control /// to pop instead of being assigned as an `NSPopUpButton`'s own `menu`. Two click targets sharing one /// menu-plus-panel contract is the one thing `NSPopUpButton` cannot do on its own: it has exactly one /// hit zone for exactly one action. struct ColorComboView: NSViewRepresentable { let role: ColorComboRole /// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field /// 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. /// 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 /// repeatedly while the user is still adjusting the colour. Kept separate from `onChange` /// because the two halves of this control's contract differ at the call site /// (`CardStyleSection`): a discrete pick commits immediately, a panel tick is the caller's to /// debounce and never feeds style recents. var onPanelChange: @MainActor (String?) -> Void /// The width `sizeThatFits` hands back when SwiftUI has no concrete proposal to fill — an /// unconstrained measuring pass, not the normal case. The normal case is a finite proposal (this /// view sits under `.frame(maxWidth: .infinity)` in the sidebar row, `CardActionsSection`'s /// Delete button's own trick), which this default never has to stand in for. private static let defaultFaceWidth: CGFloat = 120 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 coordinator?.openColorPanel() } return control } func updateNSView(_ control: ColorComboControl, context: Context) { context.coordinator.role = role context.coordinator.onChange = onChange context.coordinator.onPanelChange = onPanelChange control.isEnabled = isEnabled context.coordinator.rebuild(control, value: value) } /// Obeys whatever width SwiftUI proposes, like any other control — never the widest menu item, /// which is what the old caller-supplied `width` input existed to work around. Height is the /// control's own fitting height (`ColorComboControl.intrinsicContentSize`, about half the old /// regular `NSPopUpButton`'s — the whole point of this rework); width is the proposal's when it /// is an actual number, else `defaultFaceWidth`, since a `nil`/infinite proposal happens on an /// unconstrained measuring pass, not a sidebar row. func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? { let width: CGFloat if let proposed = proposal.width, proposed.isFinite { width = proposed } else { width = Self.defaultFaceWidth } return CGSize(width: width, height: nsView.intrinsicContentSize.height) } /// Detaches the colour panel's target/action if this coordinator still holds them — "last-writer /// wins" for any control that took the panel over afterward (this view's own doc comment). static func dismantleNSView(_ control: ColorComboControl, coordinator: Coordinator) { coordinator.detachColorPanel() } func makeCoordinator() -> Coordinator { Coordinator(role: role, onChange: onChange, onPanelChange: onPanelChange) } // MARK: Coordinator /// The one object every menu action and the colour panel's action target — a class because /// `NSColorPanel.setTarget(_:)` needs something with reference identity to detach from later, /// and `@MainActor` because every AppKit call it makes has to be. @MainActor final class Coordinator: NSObject { fileprivate var role: ColorComboRole fileprivate var currentValue: String? fileprivate var onChange: @MainActor (String?) -> Void fileprivate var onPanelChange: @MainActor (String?) -> Void /// ~44×14pt — a menu row's swatch, wide enough beside its title to read as a colour sample /// 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? init( role: ColorComboRole, onChange: @escaping @MainActor (String?) -> Void, onPanelChange: @escaping @MainActor (String?) -> Void ) { self.role = role self.onChange = onChange self.onPanelChange = onPanelChange } /// Rebuilds the dropdown for `value` and hands the control the menu, its checked item (the /// popup anchor `ColorComboControl.popUpMenu()` positions against, and the source of its /// accessibility value), and the value its swatch zone should draw. Cheap enough — a dozen /// rows, a fistful of small menu-row images — to redo wholesale on every SwiftUI update /// rather than diffing against what was there before. func rebuild(_ control: ColorComboControl, value: String?) { currentValue = value let menu = NSMenu() let built = ColorComboModel.menu(role: role, value: value) var checkedItem: NSMenuItem? for (index, item) in built.items.enumerated() { if item == .separator { menu.addItem(.separator()) continue } let menuItem = self.menuItem(for: item) let isChecked = index == built.selectedIndex menuItem.state = isChecked ? .on : .off menu.addItem(menuItem) if isChecked { checkedItem = menuItem } } control.comboMenu = menu control.checkedItem = checkedItem 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 } private func menuItem(for item: ColorComboItem) -> NSMenuItem { switch item { case .none: let menuItem = NSMenuItem(title: "None", action: #selector(selectNone), keyEquivalent: "") menuItem.target = self menuItem.image = PaletteSwatch.rectImage(for: nil, size: Self.menuSwatchSize) return menuItem case let .palette(name): let menuItem = NSMenuItem( title: ColorComboModel.displayName(name), action: #selector(selectValue(_:)), keyEquivalent: "" ) menuItem.target = self menuItem.representedObject = name menuItem.image = PaletteSwatch.rectImage(for: name, size: Self.menuSwatchSize) return menuItem case let .current(swatchValue, title): let menuItem = NSMenuItem(title: title, action: #selector(selectValue(_:)), keyEquivalent: "") menuItem.target = self menuItem.representedObject = swatchValue menuItem.image = PaletteSwatch.rectImage(for: swatchValue, size: Self.menuSwatchSize) return menuItem case .other: let menuItem = NSMenuItem(title: "Other…", action: #selector(openColorPanel), keyEquivalent: "") menuItem.target = self return menuItem case .separator: // Unreached: `rebuild` handles `.separator` before calling this. Kept so the switch // stays total against a case list a future row could still grow. return NSMenuItem.separator() } } @objc private func selectNone() { onChange(nil) } @objc private func selectValue(_ sender: NSMenuItem) { 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. /// /// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above) /// and `ColorComboControl`'s swatch-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) } } } // MARK: - Two-zone NSControl /// 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). /// /// 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 { /// 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. var swatchValue: String? { didSet { guard swatchValue != oldValue else { return } needsDisplay = true } } /// The dropdown the trigger zone pops, and the row within it that should read as checked — both /// `Coordinator.rebuild(_:value:)`'s to hand over on every rebuild, always together (`checkedItem` /// is always one of `comboMenu`'s own items). This control never builds a row itself; it only /// positions and pops what it is given. 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) NSColor.textBackgroundColor.setFill() path.fill() if let swatchValue, let color = Palette.nsColor(for: swatchValue) { color.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 * 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() { 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 } }