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 own /// 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 table it lists, never about /// which values it can resolve. enum ColorComboRole: Sendable, Equatable { case background case foreground /// The rows this picker offers — one per entry in the role's own table (sixteen since /// 2026-08-09; the count is the palette's business, not this type's). var palette: [PaletteColor] { switch self { case .background: Palette.backgrounds case .foreground: Palette.foregrounds } } /// The *other* picker's table — 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 entries, 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* own, 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 own, 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 role's own entries, 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 palette rows, 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 func makeNSView(context: Context) -> ColorComboControl { let control = ColorComboControl(frame: .zero) // The two zones' jobs, wired once (`ComboFieldControl`'s grammar): the face opens the same // panel takeover **Other…** does, the trigger pops the dropdown. Closures, not target/action // pairs — there is exactly one caller each and no `NSMenuItem`-style Objective-C boundary to // cross for them. control.onFaceClick = { [weak coordinator = context.coordinator] in coordinator?.openColorPanel() } control.onTriggerClick = { [weak control] in control?.popUpMenu() } return control } func updateNSView(_ control: ColorComboControl, context: Context) { context.coordinator.role = role context.coordinator.onChange = onChange context.coordinator.onPanelChange = onPanelChange control.metrics = .current control.isEnabled = isEnabled context.coordinator.rebuild(control, value: value) } /// Obeys an explicit finite proposal when SwiftUI hands one over — never the widest menu item, /// which is what the old caller-supplied `width` input existed to work around. Falls back to the /// control's own intrinsic width (`ComboFieldControl.intrinsicContentSize`, the 2026-08-09 /// iteration's taller-narrower field) on an unconstrained measuring pass, which is the normal case /// now that the sidebar row no longer forces a wider frame of its own (`CardStyleSection`). func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorComboControl, context: Context) -> CGSize? { let width: CGFloat if let proposed = proposal.width, proposed.isFinite { width = proposed } else { width = nsView.intrinsicContentSize.width } 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) /// This coordinator's handle on `NSColorPanel.shared` — the takeover, its continuous action /// and its last-writer-wins detach all live in `SystemColorPanel` now, shared with the two /// surfaces that gained an **Other…** on 2026-08-09. private let colorPanel = SystemColorPanel() 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.accessibilityValueText = checkedItem?.title control.swatchValue = value } /// See `ColorComboView.dismantleNSView(_:coordinator:)`. func detachColorPanel() { colorPanel.detach() } 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) } /// Opens the shared Colors panel seeded with the current resolved colour, streaming what it /// picks into `onPanelChange` — the palette name when the colour lands exactly on one of /// `role`'s own entries, the hex otherwise. Every rule of that takeover is /// `SystemColorPanel`'s; this is the seed and the destination. /// /// Two callers, one takeover: the dropdown's own **Other…** row (`#selector` target above) /// and `ColorComboControl`'s face-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() { colorPanel.present( seed: currentValue.flatMap(Palette.nsColor(for:)), matching: role.palette ) { [weak self] value in self?.onPanelChange(value) } } } } // MARK: - The control /// The collapsed face: `ComboFieldControl` with a **swatch** in its face zone. /// /// Everything that makes it a two-zone combo — the field, the hairline, the trailing chevron square, /// the hit split, the disabled compositing, the geometry — is the base class's now (ComboField.swift), /// shared with the symbol combo so the two cannot drift apart. What is left here is the one thing that /// is actually about *colour*: the swatch, and the menu the trigger pops. /// /// 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: ComboFieldControl { /// 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? /// 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. /// /// **No inset, since 2026-08-09** — the swatch fills the whole face zone rather than sitting in a /// padded ring inside it (the owner's "remove padding from the combo"). override func drawFace(in rect: NSRect) { guard rect.width > 0, rect.height > 0 else { return } let path = NSBezierPath(roundedRect: rect, xRadius: metrics.cornerRadius, yRadius: metrics.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() } /// 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. func popUpMenu() { guard let comboMenu else { return } comboMenu.popUp(positioning: checkedItem, at: NSPoint(x: 0, y: bounds.height), in: self) } }