The Colors panel joins the palette — the combo ratified, and each anchor composes the halves it needs
Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app escape hatch is ratified in full, reversing 2026-07-29's palette-only rule — the combo's Other… opens the system Colors panel, a pick landing on a palette color stores the name, anything else the hex. Free-picked colors change no contrast story: they land on the same runtime ink computation hand-written hex always got (10 amended to say so; no warning surface is owed). Anchor ownership: the card sidebar's background story is the combo alone — the well grid's background half stays with the other anchors (StyleEditorView gains showsBackground beside showsSymbols; the popover's symbol half already went to its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a panel pick never enters them. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,614 @@
|
||||
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 zone's padding before its rounded rect — two points rather than a bare hairline,
|
||||
/// so the control's own field (`drawField()`) reads as a visible ring around the colour instead
|
||||
/// of being covered by it.
|
||||
private static let swatchPadding: 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. Standard control materials: `controlBackgroundColor` fill,
|
||||
/// `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.controlBackgroundColor.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.swatchPadding, dy: Self.swatchPadding)
|
||||
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.swatchPadding
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user