The owner's follow-up review on the shipped rectangles (Pipeline card 5004c540): height was close, so it stays exactly 2.1em on both controls; width now diverges by role via PickerRectMetrics.WidthRatio (color 1.5, symbol 1.25) instead of one shared multiplier. The symbol glyph gets a small em-derived inset back (SymbolGlyphControl.glyphInset) — a sliver of breath, not the old padded ring — applied to the face rect before sizing and fitting, still centred on the same midpoint. The collapsed colour swatch reuses ColorSwatchNoneStrike's own geometry to draw the popover's None slash whenever the stored value is nil or unresolvable, rather than sitting empty. Both mounting anchors (the card sidebar's side-by-side columns, the board popover beside the rename field) pick up .fixedSize() so the controls render at their own intrinsic size instead of stretching into whatever slack an HStack proposal leaves them. KanbanTests/SymbolCatalogTests.swift and ColorSwatchPickerTests.swift updated for the per-role widths, the shared-height claim, the glyph inset rule, and the None-face predicate.
425 lines
19 KiB
Swift
425 lines
19 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// **The colour rectangle** — a plain clickable swatch (`ColorSwatchControl`, `PickerRect.swift`'s
|
|
/// shared chrome) that opens a SwiftUI popover holding the background palette grid: **None**, the
|
|
/// role's own sixteen wells, and an **Other…** row onto the system Colors panel.
|
|
///
|
|
/// ### The owner's reversal (2026-08-10, Pipeline card 5004c540, verbatim)
|
|
///
|
|
/// "...lets do same for color, just a 4:6 ratio rectangle for color, no trigger, a grid of colors, and
|
|
/// a choice for 'Other' or 'More' to pull up generic color picker."
|
|
///
|
|
/// This retires `ColorComboView` and its `NSMenu` dropdown outright — the swatch-faced two-zone combo
|
|
/// this file used to hold, whose trigger popped **None**, the role's own entries, an off-palette
|
|
/// "current" row and **Other…** as menu items. The dropdown's whole pure model (`ColorComboItem`,
|
|
/// `ColorComboMatch`, `ColorComboMenu`, `ColorComboModel.match`/`.menu`/`.displayName`/
|
|
/// `.normalizedHex`) went with it: nothing left calls a menu into being, so nothing here builds one.
|
|
///
|
|
/// `ColorSwatchRole` is the one surviving piece of the old pure model — which of the two palettes a
|
|
/// swatch offers. The new grid's wells are `role.palette`, matched by **literal name**, exactly the
|
|
/// rule `StyleEditorView.backgroundWells` already applies to the Style… popover's own background grid
|
|
/// — reusing that grid's own layout and matching convention rather than the retired dropdown's hex-
|
|
/// normalizing cleverness, so the app's two background grids agree about what "selected" means. An
|
|
/// off-palette hex that happens to equal a palette colour numerically no longer highlights that well;
|
|
/// it did in the old dropdown and does not in the Style… popover's grid either.
|
|
///
|
|
/// ### The "Other…" row and the debounce
|
|
///
|
|
/// Opens `SharedColorPanelSession` — the same shared, pre-debounced (~400ms) Colors-panel takeover
|
|
/// `StyleEditorView.openBackgroundPanel` and `SymbolPicker.openTintPanel` already use, because this
|
|
/// swatch's popover is transient exactly like theirs: opening the panel dismisses the popover, so a
|
|
/// per-view-owned panel session (what `ColorComboView`'s coordinator held) would have nothing left to
|
|
/// talk to. The write always goes through **`onPanelChange`, never `onChange`'s funnel** — "no
|
|
/// StyleRecents feed on drag ticks" (`StyleCommand.apply`'s recents feed is keyed to `background`
|
|
/// specifically, and a Colors-panel pick must never enter it, even the one settled value the debounce
|
|
/// finally delivers) — `StyleEditorView.openBackgroundPanel`'s own rule, restated here since this door
|
|
/// opens the identical session.
|
|
|
|
// MARK: - Role
|
|
|
|
/// Which of the two palettes a swatch offers — `Palette.backgrounds` for `background`, the only role
|
|
/// wired to a caller today; `Palette.foregrounds` stays for symmetry with `ColorSwatchRole`'s own
|
|
/// pre-2026-08-10 shape, unused but free.
|
|
enum ColorSwatchRole: Sendable, Equatable {
|
|
case background
|
|
case foreground
|
|
|
|
var palette: [PaletteColor] {
|
|
switch self {
|
|
case .background: Palette.backgrounds
|
|
case .foreground: Palette.foregrounds
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Geometry
|
|
|
|
/// The popover's own grid geometry — `SymbolPickerLayout`'s pattern, restated for a plain colour
|
|
/// grid: the well side and spacing read off `StyleEditorLayout`'s statics, scaled up by the same
|
|
/// `gridScale` the symbol popover's grid uses, and **seven columns** — `StyleEditorLayout.popover`'s
|
|
/// own settled fit for None + sixteen wells (7 + 7 + 3), reused here rather than re-derived, since it
|
|
/// is exactly this count of wells again.
|
|
struct ColorSwatchPopoverLayout: Equatable {
|
|
|
|
static let columns = 7
|
|
/// The grid's enlargement over the style editor's well size — `SymbolPickerLayout.gridScale`'s
|
|
/// own figure, so the two new-since-2026-08-10 popovers read at the same scale.
|
|
static let gridScale: CGFloat = 1.3
|
|
|
|
var wellSide: CGFloat
|
|
var wellSpacing: CGFloat
|
|
/// The popover's own inset, on all four sides, and the gap between the grid and the **Other…**
|
|
/// row below it.
|
|
var contentPadding: CGFloat
|
|
var gridWidth: CGFloat
|
|
/// The grid's width plus its padding on both sides — the popover's fixed width.
|
|
var popoverWidth: CGFloat
|
|
|
|
static func metrics(bodyPointSize: CGFloat) -> ColorSwatchPopoverLayout {
|
|
let side = (StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) * gridScale).rounded()
|
|
let spacing = StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
|
|
let padding = StyleEditorLayout.sectionSpacing(bodyPointSize: bodyPointSize)
|
|
let gridWidth = (side * CGFloat(columns) + spacing * CGFloat(columns - 1)).rounded()
|
|
return ColorSwatchPopoverLayout(
|
|
wellSide: side,
|
|
wellSpacing: spacing,
|
|
contentPadding: padding,
|
|
gridWidth: gridWidth,
|
|
popoverWidth: (gridWidth + padding * 2).rounded()
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - The picker
|
|
|
|
/// The public surface: a colour rectangle plus its popover, `SymbolPicker`'s own shape one dimension
|
|
/// over. No store, no undo stack, no target — `onChange`/`onPanelChange` are the whole contract, the
|
|
/// same split `ColorComboView`'s `onChange`/`onPanelChange` offered, so `CardStyleSection` keeps its
|
|
/// two-closure wiring across the control swap.
|
|
struct ColorSwatchPicker: View {
|
|
|
|
let role: ColorSwatchRole
|
|
/// The raw stored value — a palette name or a hand-written hex, exactly as the frontmatter field
|
|
/// carries it.
|
|
let value: String?
|
|
var isEnabled: Bool = true
|
|
/// A discrete pick — a grid well or **None**. Fired once, synchronously; the caller commits it
|
|
/// immediately and decides for itself whether it feeds `StyleRecents`.
|
|
let onChange: (String?) -> Void
|
|
/// A value settled from the Colors panel's **Other…** door — always raw, never through the
|
|
/// recents funnel (this file's own header).
|
|
let onPanelChange: (String?) -> Void
|
|
|
|
/// The popover's presented flag — view-local `@State`, `SymbolPicker.isPopoverPresented`'s own
|
|
/// reasoning: a plain SwiftUI `.popover` nests correctly inside another one (the board popover
|
|
/// mounts a symbol rectangle the identical way), where a hand-driven `NSPopover` would not.
|
|
@State private var isPopoverPresented = false
|
|
|
|
var body: some View {
|
|
ColorSwatchView(value: value, isEnabled: isEnabled, onClick: { isPopoverPresented = true })
|
|
.help(helpText)
|
|
.accessibilityLabel(helpText)
|
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
|
ColorSwatchPopoverContent(
|
|
current: value,
|
|
palette: role.palette,
|
|
layout: .metrics(bodyPointSize: CardWindowMetrics.bodyPointSize),
|
|
onSelect: { picked in
|
|
isPopoverPresented = false
|
|
onChange(picked)
|
|
},
|
|
onPickOther: {
|
|
isPopoverPresented = false
|
|
openColorPanel()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private var helpText: String {
|
|
role == .background ? "Background" : "Color"
|
|
}
|
|
|
|
/// **Other…** — the shared, debounced Colors panel, for the same outlives-the-popover reason
|
|
/// `SymbolPicker.openTintPanel` opens it: dismissing this popover would take a view-owned
|
|
/// coordinator with it before the panel finished streaming.
|
|
private func openColorPanel() {
|
|
SharedColorPanelSession.present(
|
|
seed: value.flatMap(Palette.nsColor(for:)),
|
|
matching: role.palette
|
|
) { picked in
|
|
onPanelChange(picked)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The AppKit bridge
|
|
|
|
/// `ColorSwatchPicker`'s face: a `ColorSwatchControl` with its one zone wired to the caller's
|
|
/// closure. Deliberately dumb, `SymbolPicker`'s own `SymbolGlyphView` split: every decision about
|
|
/// what a click means lives in SwiftUI, where the popover's presented state already does.
|
|
private struct ColorSwatchView: NSViewRepresentable {
|
|
|
|
let value: String?
|
|
let isEnabled: Bool
|
|
let onClick: () -> Void
|
|
|
|
func makeNSView(context: Context) -> ColorSwatchControl {
|
|
ColorSwatchControl(frame: .zero)
|
|
}
|
|
|
|
func updateNSView(_ control: ColorSwatchControl, context: Context) {
|
|
control.metrics = .current(.color)
|
|
control.isEnabled = isEnabled
|
|
control.swatchValue = value
|
|
control.accessibilityValueText = value ?? "None"
|
|
control.onClick = onClick
|
|
}
|
|
|
|
/// Obeys an explicit finite proposal when SwiftUI hands one over, else falls back to the
|
|
/// control's own intrinsic width — `SymbolGlyphView.sizeThatFits`'s rule, restated so the two
|
|
/// rectangles answer a proposal identically.
|
|
func sizeThatFits(_ proposal: ProposedViewSize, nsView: ColorSwatchControl, 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)
|
|
}
|
|
}
|
|
|
|
// MARK: - The control
|
|
|
|
/// `PickerRectControl` with a **swatch** filling the whole face — the colour half of the shared
|
|
/// chrome, and the whole of what is specific to colour about it.
|
|
final class ColorSwatchControl: PickerRectControl {
|
|
|
|
/// The value the face currently draws — a palette name or a hand-written hex, handed over on
|
|
/// every SwiftUI update.
|
|
var swatchValue: String? {
|
|
didSet {
|
|
guard swatchValue != oldValue else { return }
|
|
needsDisplay = true
|
|
}
|
|
}
|
|
|
|
/// The colour rect: 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 **plus the popover well's own
|
|
/// corner-to-corner slash** — the owner's 2026-08-10 follow-up ("when 'none' selected the
|
|
/// strike-through line should show on the selected color too"): the collapsed face is itself
|
|
/// showing the current value, so a value that resolves to "no colour" gets the identical None
|
|
/// treatment the grid's own leading well draws, rather than a plain empty rectangle — straight
|
|
/// into the control's own graphics context, exactly as the fill was before, rather than through
|
|
/// `PaletteSwatch.rectImage`'s intermediate `NSImage` (that function had exactly one caller, the
|
|
/// retired dropdown's menu rows, and is gone with it).
|
|
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 Self.showsNoneStrike(for: swatchValue) {
|
|
drawNoneStrike(in: rect)
|
|
} else if let color = swatchValue.flatMap(Palette.nsColor(for:)) {
|
|
color.setFill()
|
|
path.fill()
|
|
}
|
|
NSColor.separatorColor.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
}
|
|
|
|
/// Whether the face has nothing to fill and should draw the None strike instead — `nil`, and any
|
|
/// stored value this app cannot resolve to a colour, both read the same lenient way
|
|
/// `StyleWellFace.Face.color`'s own doc already states it for the grid's wells ("An unresolvable
|
|
/// value draws like `noValue`"). A pure static predicate, not inlined into `drawFace`, so the rule
|
|
/// is assertable without an actual draw — the same pattern `SymbolGlyphControl`'s sizing statics
|
|
/// already use.
|
|
static func showsNoneStrike(for value: String?) -> Bool {
|
|
value.flatMap(Palette.nsColor(for:)) == nil
|
|
}
|
|
|
|
/// Strokes `ColorSwatchNoneStrike`'s own corner-to-corner slash (below) across `rect` — the
|
|
/// popover well's exact geometry, reused rather than restated, so the grid's None well and this
|
|
/// collapsed face agree pixel for pixel about what "no colour" looks like. `ColorSwatchNoneStrike`
|
|
/// is a plain SwiftUI `Shape`; its `path(in:)` is pure geometry with no SwiftUI environment or
|
|
/// rendering context of its own, so calling it here and stroking the result straight into this
|
|
/// control's AppKit graphics context is exactly as valid as the popover calling it inside
|
|
/// `.stroke(...)`. The inset reads off `min(rect.width, rect.height)` rather than `layout.
|
|
/// wellSide` — the well grid's own wells are square, this face is not (6:4/5:4), so the shorter
|
|
/// side stands in for it.
|
|
private func drawNoneStrike(in rect: NSRect) {
|
|
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
|
let inset = max(1, (min(rect.width, rect.height) * 0.15).rounded())
|
|
context.saveGState()
|
|
context.addPath(ColorSwatchNoneStrike(inset: inset).path(in: rect).cgPath)
|
|
context.setStrokeColor(NSColor.secondaryLabelColor.cgColor)
|
|
context.setLineWidth(1)
|
|
context.strokePath()
|
|
context.restoreGState()
|
|
}
|
|
}
|
|
|
|
// MARK: - The popover's content
|
|
|
|
/// The popover's body: the palette grid, then **Other…** — `SymbolPickerPopoverContent`'s shape, one
|
|
/// grid narrower (no search, no second grid): this popover has exactly one dimension to offer.
|
|
private struct ColorSwatchPopoverContent: View {
|
|
|
|
let current: String?
|
|
let palette: [PaletteColor]
|
|
let layout: ColorSwatchPopoverLayout
|
|
let onSelect: (String?) -> Void
|
|
let onPickOther: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: layout.wellSpacing) {
|
|
ColorSwatchWellGrid(current: current, palette: palette, layout: layout, onSelect: onSelect)
|
|
// Trailing-aligned link row onto the standalone panel — `SymbolPickerPopoverContent.
|
|
// deeperRow`'s own shape, restated: a well that is not a value is a well that has to be
|
|
// learned, and the row already spells this door as a titled affordance.
|
|
HStack(spacing: 0) {
|
|
Spacer(minLength: 0)
|
|
Button("Other…", action: onPickOther)
|
|
.buttonStyle(.link)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.padding(layout.contentPadding)
|
|
.frame(width: layout.popoverWidth)
|
|
}
|
|
}
|
|
|
|
// MARK: - Wells
|
|
|
|
/// One well in the grid: a palette name, or `nil` for the leading **None**.
|
|
private struct ColorSwatchWell: Identifiable {
|
|
let id: Int
|
|
let name: String?
|
|
let label: String
|
|
let isSelected: Bool
|
|
}
|
|
|
|
/// The palette grid: the leading **None** well and `palette`'s own entries, seven wide —
|
|
/// `StyleWellGrid`'s pattern (StyleEditor.swift), mirrored rather than shared for the reason
|
|
/// `SymbolWellGrid`/`SymbolColorGrid` (SymbolPicker.swift) already mirror it instead of widening that
|
|
/// file's `private` access for one caller outside it.
|
|
private struct ColorSwatchWellGrid: View {
|
|
|
|
let current: String?
|
|
let palette: [PaletteColor]
|
|
let layout: ColorSwatchPopoverLayout
|
|
let onSelect: (String?) -> Void
|
|
|
|
@FocusState private var focused: Int?
|
|
@Environment(\.colorSchemeContrast) private var contrast
|
|
|
|
var body: some View {
|
|
LazyVGrid(
|
|
columns: Array(
|
|
repeating: GridItem(.flexible(minimum: layout.wellSide), spacing: layout.wellSpacing),
|
|
count: ColorSwatchPopoverLayout.columns
|
|
),
|
|
spacing: layout.wellSpacing
|
|
) {
|
|
ForEach(wells) { well in
|
|
Button {
|
|
onSelect(well.name)
|
|
} label: {
|
|
swatch(well.name.flatMap(Palette.color(named:)))
|
|
.overlay(selectionRing(well.isSelected))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.focusable()
|
|
.focused($focused, equals: well.id)
|
|
.help(well.label)
|
|
.accessibilityLabel(well.label)
|
|
.accessibilityAddTraits(well.isSelected ? [.isSelected] : [])
|
|
}
|
|
}
|
|
.onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow], phases: .down) { press in
|
|
move(press.key)
|
|
}
|
|
}
|
|
|
|
/// **None** leads, selected whenever `current` is absent — literal-name matching only
|
|
/// (`ColorSwatchPicker`'s own header), so an off-palette hex leaves every well unselected exactly
|
|
/// as `StyleEditorView.backgroundWells` already does.
|
|
private var wells: [ColorSwatchWell] {
|
|
var wells = [ColorSwatchWell(id: 0, name: nil, label: "None", isSelected: current == nil)]
|
|
for (index, color) in palette.enumerated() {
|
|
wells.append(ColorSwatchWell(
|
|
id: index + 1,
|
|
name: color.name,
|
|
label: color.name,
|
|
isSelected: current == color.name
|
|
))
|
|
}
|
|
return wells
|
|
}
|
|
|
|
/// A colour swatch, always stroked (`chalk` is `#FFFFFF`; an unbordered white swatch is an
|
|
/// invisible control), with the corner-to-corner slash standing in for "no colour" on the None
|
|
/// well — `StyleWellFace`'s own vocabulary, restated.
|
|
private func swatch(_ color: Color?) -> some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
|
.overlay {
|
|
if color == nil {
|
|
ColorSwatchNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded()))
|
|
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
|
}
|
|
}
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
|
|
)
|
|
.frame(width: layout.wellSide, height: layout.wellSide)
|
|
}
|
|
|
|
private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.2).rounded()) }
|
|
|
|
private func selectionRing(_ isSelected: Bool) -> some View {
|
|
RoundedRectangle(cornerRadius: cornerRadius)
|
|
.strokeBorder(
|
|
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
|
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
|
)
|
|
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
|
}
|
|
|
|
/// `SymbolWellGrid.move(_:)`'s rule, at this grid's own seven columns.
|
|
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
|
let delta: Int
|
|
switch key {
|
|
case .leftArrow: delta = -1
|
|
case .rightArrow: delta = 1
|
|
case .upArrow: delta = -ColorSwatchPopoverLayout.columns
|
|
case .downArrow: delta = ColorSwatchPopoverLayout.columns
|
|
default: return .ignored
|
|
}
|
|
let current = focused ?? 0
|
|
let next = min(max(0, current + delta), wells.count - 1)
|
|
focused = next
|
|
return .handled
|
|
}
|
|
}
|
|
|
|
/// The None well's corner-to-corner slash — `StyleEditor.swift`'s `NoValueStrike`/`SymbolPicker.
|
|
/// swift`'s `ColorNoneStrike`, restated as a third sibling for the same reason those two are already
|
|
/// siblings rather than one shared exported shape.
|
|
private struct ColorSwatchNoneStrike: Shape {
|
|
let inset: CGFloat
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
|
|
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
|
|
return path
|
|
}
|
|
}
|