Owner's first review of the combo rework (2026-08-09): remove the face padding, make the field taller and narrower at about a 4:5 width:height ratio, and center the symbol glyph in its face. All three land in ComboFieldMetrics, so ColorComboControl and SymbolComboControl stay the identical shape they were built to share. - ComboFieldMetrics grows a width figure (height * widthToHeightRatio, 0.8), replacing NSView.noIntrinsicMetric — every combo now carries its own taller, narrower intrinsic size instead of stretching to whatever a caller's frame proposed. - facePaddingH/facePaddingV/glyphPadding are gone; a face fills its zone edge to edge. glyphPointSize is now whichever of the face's own width/height is smaller, with nothing subtracted for padding that no longer exists. - SymbolComboControl.drawFace centers the glyph on both axes — it only ever centered vertically before, despite its own doc comment claiming otherwise. - ComboFieldControl.drawTrigger bounds its chevron square by the smaller of the trigger strip's own width/height, not height alone, since the strip is no longer close to square once the field is much taller than it is wide. - CardSidebarSections drops the sidebar's old '* 0.55' fixed-width frame on both combo rows; each control now sizes itself, and both anchors (card sidebar, board popover) compose the narrower field with no other changes needed. - ComboFieldMetricsTests updated for the new figures, plus a ratio-holds-at-every-size test and a rewritten glyph-fit test matching the no-padding rule. Verification: xcodebuild build succeeded; xcodebuild test -only-testing:KanbanTests — 3220 tests in 559 suites, 3 failures, all PointerLatencyTests (documented locked-screen environmental mode, confirmed unrelated by isolated rerun). Pixel verification unexercised — same locked-screen constraint the first pass hit. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
828 lines
40 KiB
Swift
828 lines
40 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// **A reusable SF Symbol picker** — a two-zone combo showing the resolved symbol, opening a curated
|
||
/// grid from its trigger and the standalone browser from its face (03-board-ui.md § Styling ▸
|
||
/// Controls: "its leading well is the level's default symbol and removes the `icon` key … Any other
|
||
/// SF Symbol name works written by hand"). `StyleEditor.swift`
|
||
/// already builds that grid once, aimed at `background`/`icon` together and multiplexed across three
|
||
/// anchors; this file builds the *symbol half alone*, aimed at any single field a caller names, so a
|
||
/// control that only ever needs one glyph — a saved search, a smart filter, a future per-item
|
||
/// affordance — is not forced to carry the style editor's background section or its `BoardStore`
|
||
/// coupling to get one.
|
||
///
|
||
/// ### Why the curated set differs from `CuratedSymbols`
|
||
///
|
||
/// `CuratedSymbols` is three sets grouped by *level* — boards, lanes, cards (2026-08-09) — because a
|
||
/// board's identity, a lane's stage and a card's content want different glyphs. This control has no
|
||
/// level built in; a caller aimed at one names it explicitly (`BoardInfoPopover` passes
|
||
/// `CuratedSymbols.availableBoards`, the card sidebar `CuratedSymbols.availableCards`), and a caller
|
||
/// with no level in mind — a saved search, a smart filter — falls back to `SymbolPickerCatalog.defaultSet`,
|
||
/// a smaller, ungrouped 36 chosen for the general "boards and projects" case instead. The lists are
|
||
/// free to diverge; nothing here reads the others beyond the merge below.
|
||
///
|
||
/// ### The one thing `CuratedSymbols` never needed
|
||
///
|
||
/// The style editor's curated grid has no search and no full-catalog fallback ("no full-browser
|
||
/// escape hatch in-app" is a statement about *that* surface). This picker adds one anyway, because a
|
||
/// general-purpose control cannot assume its 36 will always contain what the caller is after — a
|
||
/// search with nothing to search would just move the dead end from "no matching well" to "no way to
|
||
/// look further".
|
||
///
|
||
/// ### Shape and grammar (2026-08-09): the two pickers rhyme
|
||
///
|
||
/// This control used to be a single 20pt bordered square with one hit zone, sitting in a sidebar
|
||
/// beside a wide two-zone colour combo that had a *second* door onto `NSColorPanel.shared`. Two
|
||
/// controls setting adjacent frontmatter keys, looking and behaving nothing alike. Now it is a
|
||
/// `ComboFieldControl` like the colour combo — same field, same height, same trailing chevron, by
|
||
/// construction rather than by agreement — with the same two-zone grammar:
|
||
///
|
||
/// - **the face** opens `SymbolBrowserPanel`, the standalone searchable/categorised browser, exactly
|
||
/// as the colour combo's swatch opens the Colors panel;
|
||
/// - **the trigger** pops the curated popover this file always had.
|
||
///
|
||
/// And that popover gained the two rows the grammar implies: **More Symbols…** onto the browser (the
|
||
/// dropdown's **Other…**, one dimension over — and the only *keyboard* route to the face zone's
|
||
/// door), and an **Other…** under the tint grid onto the Colors panel. The tint grid itself grew from
|
||
/// 4×2 to 4×3, which is what the palette went from twelve entries to sixteen to make room for
|
||
/// (`Palette.tints`).
|
||
|
||
// MARK: - The symbol catalogs
|
||
|
||
/// The picker's two symbol lists: the curated 36-glyph grid it opens with, and the OS's full
|
||
/// inventory it searches into once the grid alone isn't enough.
|
||
enum SymbolPickerCatalog {
|
||
|
||
/// The picker's curated grid, in order — a general "boards and projects" set rather than one of
|
||
/// the style editor's level-specific groupings, chosen so a first-run picker with no
|
||
/// caller-supplied `symbols` still shows something broadly useful. A stored constant, not a
|
||
/// computed property, for `CuratedSymbols`' own reason: the list is the design decision, and
|
||
/// `available` is the only thing the OS gets a say in.
|
||
static let defaultSet: [String] = [
|
||
"star", "flag", "heart", "bolt", "flame", "leaf", "drop", "sun.max", "moon", "sparkles",
|
||
"tag", "bookmark", "pin", "bell", "paperplane", "tray", "folder", "archivebox", "doc.text",
|
||
"list.bullet", "checklist", "calendar", "clock", "hammer", "wrench.and.screwdriver",
|
||
"paintbrush", "lightbulb", "brain", "book", "graduationcap", "briefcase", "cart", "house",
|
||
"airplane", "gamecontroller", "globe",
|
||
]
|
||
|
||
/// The set this Mac can actually draw — `CuratedSymbols.available(for:)`'s rule, mirrored: a
|
||
/// curated list is a convenience, never a claim about the running system.
|
||
static var available: [String] { defaultSet.filter(ItemSymbol.exists) }
|
||
|
||
/// The colour row's tints — `Palette.tints`' names, which is the icon-tint palette minus the four
|
||
/// grayscale steps (a symbol's *tint* wants colour; "no tint" is the None well's job, not a
|
||
/// gray's) and minus `deep-cool-granite`, the mutedest of the ring. Palette names, not hexes,
|
||
/// exactly as the style editor's wells write them.
|
||
///
|
||
/// **Eleven now, seven before** — the palette grew to sixteen on 2026-08-09 and the exclusions
|
||
/// did not, so the row gained its third 4-wide row (`SymbolPickerLayout.colorRows`) and its
|
||
/// leading None still fills the grid exactly. That fit is asserted, not hoped for
|
||
/// (`SymbolPickerColorSetTests`).
|
||
///
|
||
/// Derived from `Palette` rather than hand-listed: the old constant restated seven names the
|
||
/// palette already knew, which is one rename away from a dead well.
|
||
static var colorSet: [String] { Palette.tints.map(\.name) }
|
||
|
||
/// Where the OS keeps the full SF Symbols inventory — read-only system metadata, present on
|
||
/// every Mac that ships SF Symbols at all.
|
||
private static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle"
|
||
|
||
/// The default path's catalog, loaded once. A `static let` rather than a `lazy var`: the load is
|
||
/// synchronous and the result is a plain `[String]` — Sendable, immutable once computed — so
|
||
/// Swift's usual thread-safe one-time global initialization is the whole of the "cache" this
|
||
/// needs, with no actor to hang it off.
|
||
private static let cachedFullCatalog: [String] = load(bundlePath: defaultBundlePath)
|
||
|
||
/// Every SF Symbol name the running OS knows, sorted and deduplicated — the search grid's source.
|
||
///
|
||
/// **Not filtered through `ItemSymbol.exists`.** The plist this reads already reflects the
|
||
/// running OS's own inventory (it *is* the OS's inventory), and running a few thousand
|
||
/// `NSImage(systemSymbolName:)` lookups against it on every search keystroke would be pure cost
|
||
/// for an answer the file has already given for free. A curated list is different: it is a
|
||
/// hand-written guess that might be stale, and only guesses need checking.
|
||
///
|
||
/// `bundlePath` defaults to the real system location and is cached there; any other path — the
|
||
/// test suite's nonexistent one, chiefly — reloads (and re-falls-back) on every call, which is
|
||
/// the honest cost of asking a question the cache was never built to answer.
|
||
static func fullCatalog(bundlePath: String = defaultBundlePath) -> [String] {
|
||
bundlePath == defaultBundlePath ? cachedFullCatalog : load(bundlePath: bundlePath)
|
||
}
|
||
|
||
/// The plist read, and its one fallback: a bundle that won't open, a resource that isn't there,
|
||
/// or a `"symbols"` key that isn't the dictionary this format has always used all read the same
|
||
/// way — as "no inventory to read" — rather than as three different failure modes to chase. The
|
||
/// merged curated set is never empty, so the picker always has *something* to search, even on a
|
||
/// system whose metadata this reader cannot make sense of.
|
||
private static func load(bundlePath: String) -> [String] {
|
||
guard
|
||
let bundle = Bundle(path: bundlePath),
|
||
let plistPath = bundle.path(forResource: "name_availability", ofType: "plist"),
|
||
let data = FileManager.default.contents(atPath: plistPath),
|
||
let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
|
||
let root = plist as? [String: Any],
|
||
let symbols = root["symbols"] as? [String: Any]
|
||
else {
|
||
return Set(defaultSet + CuratedSymbols.combined).sorted()
|
||
}
|
||
return symbols.keys.sorted()
|
||
}
|
||
|
||
/// `symbols` narrowed to the names matching `query` — pure, so the AND semantics and the
|
||
/// order-preservation are assertable without a picker on screen.
|
||
///
|
||
/// Whitespace-trimmed first, and an empty result of that is "no query", not "match nothing" — a
|
||
/// freshly opened search field must show the full catalog, not a blank grid. A non-empty query
|
||
/// splits into whitespace-separated tokens, every one of which must appear, case-insensitively,
|
||
/// somewhere in the name: `"wrench screw"` finds `wrench.and.screwdriver` the way a Spotlight-style
|
||
/// search would, rather than requiring the words adjacent or in order.
|
||
static func filter(_ query: String, in symbols: [String]) -> [String] {
|
||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return symbols }
|
||
let tokens = trimmed.split(whereSeparator: { $0.isWhitespace }).map { $0.lowercased() }
|
||
return symbols.filter { name in
|
||
let lowered = name.lowercased()
|
||
return tokens.allSatisfy { lowered.contains($0) }
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Geometry
|
||
|
||
/// The picker's font-derived geometry — well side, well spacing, the fixed 6×6 grid, and the
|
||
/// popover's own padding — following `StyleEditorLayout`'s derivation rather than restating it: the
|
||
/// base well side and spacing are read straight off `StyleEditorLayout`'s statics, then the grid's
|
||
/// wells and glyphs scale up by `gridScale` — a deliberate, user-tuned enlargement (the picker's grid
|
||
/// is this popover's whole subject, where the style editor's is one section among several), still
|
||
/// anchored to the shared base so the two components move together at every text size.
|
||
///
|
||
/// **The at-rest control's geometry is not here** — it is `ComboFieldMetrics`', shared with the
|
||
/// colour combo, which is what makes the two the same size (2026-08-09). This type now describes the
|
||
/// popover alone. Only the shape wraps a picker's own frame around them — six
|
||
/// columns fixed (not a
|
||
/// caller-configurable count, since a picker has no anchor-width story the way `StyleEditorLayout`'s
|
||
/// sidebar/popover split does), and a total padded width that is fixed for the same reason the
|
||
/// style editor's popover frame is: a popover is a window this app sizes, and a resizing one across
|
||
/// keystrokes would be distracting rather than helpful.
|
||
struct SymbolPickerLayout: Equatable {
|
||
|
||
static let columns = 6
|
||
static let rows = 6
|
||
/// The colour row's own shape — 4×3, the leading None plus `SymbolPickerCatalog.colorSet`'s
|
||
/// eleven tints. Was 4×2 and seven until the palette grew (2026-08-09).
|
||
static let colorColumns = 4
|
||
static let colorRows = 3
|
||
/// The grid's enlargement over the style editor's well size — glyphs read at a glance rather
|
||
/// than in miniature.
|
||
static let gridScale: CGFloat = 1.3
|
||
|
||
/// The glyph's own point size inside a grid well — the body size under `gridScale`, since a
|
||
/// symbol renders at the font size, not the frame; a bigger well alone would just add margin.
|
||
var glyphPointSize: CGFloat
|
||
var wellSide: CGFloat
|
||
var wellSpacing: CGFloat
|
||
/// The gap between the search field and the grid below it — one figure rather than a pixel
|
||
/// literal, so Dynamic Type moves it with everything else (10-accessibility.md's full-relative-
|
||
/// scaling rule).
|
||
var searchSpacing: CGFloat
|
||
/// The popover's own inset, on all four sides.
|
||
var contentPadding: CGFloat
|
||
var gridWidth: CGFloat
|
||
/// The search grid's scroll cap — six rows tall, so a long result list scrolls inside the popover
|
||
/// rather than growing it.
|
||
var gridHeight: CGFloat
|
||
/// A colour well's width: the symbol grid's width re-divided into four columns, so the colour
|
||
/// rows sit flush under the symbol grid rather than introducing a second width. Height stays
|
||
/// `wellSide` — the swatch is wide, not tall.
|
||
var colorWellWidth: CGFloat
|
||
/// The grid's width plus its padding on both sides — the popover's fixed width.
|
||
var popoverWidth: CGFloat
|
||
|
||
static func metrics(bodyPointSize: CGFloat) -> SymbolPickerLayout {
|
||
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()
|
||
let gridHeight = (side * CGFloat(rows) + spacing * CGFloat(rows - 1)).rounded()
|
||
return SymbolPickerLayout(
|
||
glyphPointSize: (bodyPointSize * gridScale).rounded(),
|
||
wellSide: side,
|
||
wellSpacing: spacing,
|
||
searchSpacing: spacing,
|
||
contentPadding: padding,
|
||
gridWidth: gridWidth,
|
||
gridHeight: gridHeight,
|
||
colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
|
||
popoverWidth: (gridWidth + padding * 2).rounded()
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - The control
|
||
|
||
/// A two-zone symbol combo — the reusable primitive a caller can aim at one symbol field without
|
||
/// wiring up a `BoardStore`, a `StyleTarget`, or the two-dimension batch machinery `StyleEditorView`
|
||
/// carries for the board's own background+icon editor.
|
||
///
|
||
/// **No store, no undo stack, no target set** is known to this type; `onSelect`/`onSelectColor` are
|
||
/// the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding would be.
|
||
/// That is unchanged by the 2026-08-09 rework — the API is byte for byte what it was, so every
|
||
/// existing caller kept working while the control underneath became a `ComboFieldControl`.
|
||
struct SymbolPicker: View {
|
||
|
||
/// The committed symbol name, or `nil` for "no override" — read alongside `fallback` rather than
|
||
/// pre-resolved by the caller, so this view (and only this view) has to know the lenient-render
|
||
/// rule (`ItemSymbol.name(_:fallback:)`'s rule, restated for a plain `String?` since a caller here
|
||
/// may have no `FieldValue` at all).
|
||
let current: String?
|
||
/// The level default shown when `current` is absent or unresolvable, and the grid's leading well.
|
||
let fallback: String
|
||
/// The curated grid's contents. Defaults to `SymbolPickerCatalog.available` so a caller with no
|
||
/// opinion gets the general-purpose set; a caller styling a specific domain (a template chooser,
|
||
/// say) can supply its own.
|
||
var symbols: [String] = SymbolPickerCatalog.available
|
||
/// Whether the popover offers the search field and full-catalog fallback at all. `false` collapses
|
||
/// the picker to the curated grid alone — a caller with no use for the OS's whole inventory
|
||
/// (a fixed small vocabulary) is not forced to carry the search chrome anyway.
|
||
var searchable: Bool = true
|
||
/// The name to set, or `nil` to clear back to the default — mirrors `StyleChange`'s `set`/`remove`
|
||
/// split without importing that type, since a caller outside the styling system has no `StyleChange`
|
||
/// to hand back.
|
||
let onSelect: (String?) -> Void
|
||
/// The committed tint (`iconColor`), or `nil` for "no tint" — read only when `onSelectColor` is
|
||
/// wired, since a picker with no colour row has no tint to state.
|
||
var currentColor: String? = nil
|
||
/// The colour row's contract, `onSelect`'s shape one dimension over: a palette name to set, or
|
||
/// `nil` to clear the tint. **`nil` here means no colour row at all** — the grid is opt-in, so
|
||
/// the callers that wanted a symbol picker keep getting exactly one.
|
||
var onSelectColor: ((String?) -> Void)? = nil
|
||
|
||
@Environment(\.isEnabled) private var isEnabled
|
||
|
||
/// The curated popover's presented flag — **view-local, and a SwiftUI `.popover` deliberately**.
|
||
///
|
||
/// The rework's first cut popped an `NSPopover` straight from the control, which is the natural
|
||
/// thing for an `NSView` to do and is wrong here: this picker is mounted *inside* another popover
|
||
/// at one of its anchors (`BoardInfoPopover`, beside the rename field), and a second popover that
|
||
/// takes key status from the first would dismiss the surface it was opened from. SwiftUI already
|
||
/// handles that nesting, and did before this card; keeping the presentation where it was is the
|
||
/// change that is not being made.
|
||
@State private var isPopoverPresented = false
|
||
|
||
var body: some View {
|
||
SymbolComboView(
|
||
current: current,
|
||
fallback: fallback,
|
||
isEnabled: isEnabled,
|
||
currentColor: onSelectColor == nil ? nil : currentColor,
|
||
onFace: { openBrowser() },
|
||
onTrigger: { isPopoverPresented = true }
|
||
)
|
||
.help("Symbol")
|
||
.accessibilityLabel("Symbol")
|
||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||
SymbolPickerPopoverContent(
|
||
current: current,
|
||
fallback: fallback,
|
||
symbols: symbols,
|
||
searchable: searchable,
|
||
layout: SymbolPickerLayout.metrics(bodyPointSize: CardWindowMetrics.bodyPointSize),
|
||
onSelect: { name in
|
||
isPopoverPresented = false
|
||
onSelect(name)
|
||
},
|
||
// **More Symbols…** — the popover's route to the face zone's door, and the only one
|
||
// the keyboard has (`ComboField.swift`'s note on why the face has no key of its own).
|
||
onBrowse: {
|
||
isPopoverPresented = false
|
||
openBrowser()
|
||
},
|
||
currentColor: currentColor,
|
||
onSelectColor: onSelectColor.map { select in
|
||
{ name in
|
||
isPopoverPresented = false
|
||
select(name)
|
||
}
|
||
},
|
||
// **Other…** under the tint grid — the colour combo's own row, one field over.
|
||
onPickCustomColor: onSelectColor.map { select in
|
||
{
|
||
isPopoverPresented = false
|
||
openTintPanel(select)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The face zone's door, and **More Symbols…**' — one call, because they are one gesture spelled
|
||
/// two ways (`ComboField.swift`'s grammar).
|
||
///
|
||
/// The closure handed over **outlives this view on purpose**. Opening the browser dismisses
|
||
/// whichever popover this picker was mounted in, which would take a view-owned closure with it and
|
||
/// leave the browser picking into nothing; the shared panel keeps the last closure it was given
|
||
/// until another surface claims it or its own window closes (`SymbolBrowserPanel`).
|
||
private func openBrowser() {
|
||
SymbolBrowserPanel.shared.present(current: current, fallback: fallback, onSelect: onSelect)
|
||
}
|
||
|
||
/// The tint's **Other…** — the shared, debounced Colors panel, for the same
|
||
/// outlives-the-popover reason (`SharedColorPanelSession`). Seeded with the live tint, matched
|
||
/// against the icon palette so a pick landing on one of its colours stores the *name*.
|
||
private func openTintPanel(_ select: @escaping (String?) -> Void) {
|
||
SharedColorPanelSession.present(
|
||
seed: currentColor.flatMap(Palette.nsColor(for:)),
|
||
matching: Palette.foregrounds
|
||
) { value in
|
||
select(value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - The AppKit bridge
|
||
|
||
/// `SymbolPicker`'s face: a `SymbolComboControl` with its two zones wired to the caller's closures.
|
||
///
|
||
/// Deliberately **dumb** — it draws and it reports clicks. Every decision about what a click *means*
|
||
/// (which popover, which panel, what closure survives what) is `SymbolPicker`'s, in SwiftUI, where
|
||
/// the presentation modifiers and the state that drives them already live. `ColorComboView` carries a
|
||
/// coordinator because its dropdown is an `NSMenu` that has to be built in AppKit; this one has no
|
||
/// such need and gets no such thing.
|
||
///
|
||
/// `.disabled(_:)` reaches it through `@Environment(\.isEnabled)` on `SymbolPicker` rather than a
|
||
/// parameter, since callers already spell the lock that way (`CardStyleSection`, `BoardInfoPopover`).
|
||
private struct SymbolComboView: NSViewRepresentable {
|
||
|
||
let current: String?
|
||
let fallback: String
|
||
let isEnabled: Bool
|
||
/// The live tint, or `nil` for the standard label colour — already gated by the caller on whether
|
||
/// a colour row is offered at all, so this view has no opinion about it.
|
||
let currentColor: String?
|
||
let onFace: () -> Void
|
||
let onTrigger: () -> Void
|
||
|
||
/// What the face draws — `current` if this system can resolve it, `fallback` otherwise. The
|
||
/// lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated because this
|
||
/// control's `current` arrives as a plain optional string.
|
||
private var resolvedName: String {
|
||
if let current, ItemSymbol.exists(current) { return current }
|
||
return fallback
|
||
}
|
||
|
||
func makeNSView(context: Context) -> SymbolComboControl {
|
||
SymbolComboControl(frame: .zero)
|
||
}
|
||
|
||
func updateNSView(_ control: SymbolComboControl, context: Context) {
|
||
control.metrics = .current
|
||
control.isEnabled = isEnabled
|
||
control.glyphName = resolvedName
|
||
control.glyphTint = currentColor.flatMap(Palette.nsColor(for:))
|
||
control.accessibilityValueText = resolvedName
|
||
// Rewired on every update rather than once in `makeNSView`: the closures close over this
|
||
// struct's current values, and a stale one would aim the browser at the field this picker
|
||
// used to be pointed at.
|
||
control.onFaceClick = onFace
|
||
control.onTriggerClick = onTrigger
|
||
}
|
||
|
||
/// Obeys an explicit finite proposal when SwiftUI hands one over, else falls back to the control's
|
||
/// own intrinsic width — `ColorComboView.sizeThatFits`'s rule, restated so the two combos answer a
|
||
/// proposal identically and a caller that frames them alike gets two controls the same size.
|
||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: SymbolComboControl, 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 face
|
||
|
||
/// `ComboFieldControl` with a **glyph** in its face zone — the symbol half of the shared chrome, and
|
||
/// the whole of what is specific to symbols about it.
|
||
final class SymbolComboControl: ComboFieldControl {
|
||
|
||
/// The resolved name the face draws — the coordinator's `resolvedName`, handed over on every
|
||
/// SwiftUI update.
|
||
var glyphName: String = ItemSymbol.card {
|
||
didSet {
|
||
guard glyphName != oldValue else { return }
|
||
needsDisplay = true
|
||
}
|
||
}
|
||
|
||
/// The live `iconColor`, or `nil` for the standard label colour. Lenient exactly like the glyph:
|
||
/// an unresolvable value tints nothing.
|
||
var glyphTint: NSColor? {
|
||
didSet {
|
||
guard glyphTint != oldValue else { return }
|
||
needsDisplay = true
|
||
}
|
||
}
|
||
|
||
/// The glyph, centred and tinted. A name this system cannot draw falls back to the dashed
|
||
/// question mark the wells use — the same "show that there is nothing here" the grids draw.
|
||
///
|
||
/// **Centred on both axes since 2026-08-09** — the doc comment above always said "centred" but the
|
||
/// draw only ever centred vertically and offset from the leading edge by the swatch's own
|
||
/// horizontal padding, which read as left-aligned once the glyph was more than a sliver narrower
|
||
/// than the face. The owner's review named this directly ("for symbol picker center the symbol").
|
||
override func drawFace(in rect: NSRect) {
|
||
guard rect.width > 0, rect.height > 0 else { return }
|
||
let name = ItemSymbol.exists(glyphName) ? glyphName : "questionmark.square.dashed"
|
||
let config = NSImage.SymbolConfiguration(pointSize: metrics.glyphPointSize, weight: .regular)
|
||
.applying(.init(paletteColors: [glyphTint ?? .labelColor]))
|
||
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
|
||
.withSymbolConfiguration(config)
|
||
else { return }
|
||
let size = image.size
|
||
image.draw(in: NSRect(
|
||
x: rect.midX - size.width / 2,
|
||
y: rect.midY - size.height / 2,
|
||
width: size.width,
|
||
height: size.height
|
||
))
|
||
}
|
||
}
|
||
|
||
// MARK: - The popover's content
|
||
|
||
/// The popover's body: the search field (when `searchable`), and either the curated grid or a
|
||
/// live search result — never both, since a query and the at-rest curated set answer the same
|
||
/// question two different ways.
|
||
private struct SymbolPickerPopoverContent: View {
|
||
|
||
let current: String?
|
||
let fallback: String
|
||
let symbols: [String]
|
||
let searchable: Bool
|
||
let layout: SymbolPickerLayout
|
||
let onSelect: (String?) -> Void
|
||
/// Opens the standalone browser — the popover's route to the face zone's door, present whenever
|
||
/// the caller wired one.
|
||
var onBrowse: (() -> Void)? = nil
|
||
var currentColor: String? = nil
|
||
/// `nil` is "no colour row" — `SymbolPicker.onSelectColor`'s opt-in, passed through.
|
||
var onSelectColor: ((String?) -> Void)? = nil
|
||
/// Opens the Colors panel for the tint. Wired only where the colour row is.
|
||
var onPickCustomColor: (() -> Void)? = nil
|
||
|
||
@State private var query = ""
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: layout.searchSpacing) {
|
||
if searchable {
|
||
searchField
|
||
}
|
||
resultBody
|
||
if let onBrowse {
|
||
deeperRow("More Symbols…", action: onBrowse)
|
||
}
|
||
// The colour row rides below whichever grid is up — a search narrows the symbols, not
|
||
// the tints, so the row keeps standing where the eye left it.
|
||
if let onSelectColor {
|
||
Divider()
|
||
SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
|
||
if let onPickCustomColor {
|
||
deeperRow("Other…", action: onPickCustomColor)
|
||
}
|
||
}
|
||
}
|
||
.padding(layout.contentPadding)
|
||
.frame(width: layout.popoverWidth)
|
||
}
|
||
|
||
/// A row onto a standalone picker — **More Symbols…** under the glyph grid, **Other…** under the
|
||
/// tint grid. Trailing-aligned link buttons rather than extra wells in the grids above them: a
|
||
/// well that is not a value is a well that has to be *learned*, and the colour combo's dropdown
|
||
/// already spells this door as a titled row.
|
||
private func deeperRow(_ title: String, action: @escaping () -> Void) -> some View {
|
||
HStack(spacing: 0) {
|
||
Spacer(minLength: 0)
|
||
Button(title, action: action)
|
||
.buttonStyle(.link)
|
||
.font(.caption)
|
||
}
|
||
}
|
||
|
||
private var searchField: some View {
|
||
TextField("Search Symbols", text: $query)
|
||
.textFieldStyle(.roundedBorder)
|
||
// **Escape steps outward one layer per press** (`BoardRenameField`'s idiom, the app's
|
||
// standing Escape grammar): a non-empty query clears itself and keeps the popover open,
|
||
// an empty one lets the press through to the popover's own dismissal.
|
||
.onKeyPress(.escape) {
|
||
guard !query.isEmpty else { return .ignored }
|
||
query = ""
|
||
return .handled
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var resultBody: some View {
|
||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
SymbolWellGrid(wells: curatedWells, layout: layout) { well in
|
||
onSelect(well.isDefault ? nil : well.name)
|
||
}
|
||
} else {
|
||
let matches = SymbolPickerCatalog.filter(query, in: SymbolPickerCatalog.fullCatalog())
|
||
if matches.isEmpty {
|
||
Text("No matches")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.padding(.vertical, layout.wellSpacing)
|
||
} else {
|
||
ScrollView(.vertical) {
|
||
SymbolWellGrid(wells: matchWells(matches), layout: layout) { well in
|
||
onSelect(well.name)
|
||
}
|
||
}
|
||
.frame(height: layout.gridHeight)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The at-rest grid: the leading default well, then up to 35 more from `symbols` — 03-board-ui.md
|
||
/// § Styling ▸ Controls' "leading well is the level's default symbol" rule, restated for this
|
||
/// control's plain-optional `current`/`fallback` pair.
|
||
///
|
||
/// `fallback` is dropped from the trailing set if present, so the default is never drawn twice —
|
||
/// which is also why the trailing set is 35 rather than 36: the two together fill the 6×6 grid
|
||
/// exactly when `fallback` was one of `symbols` to begin with (as it is for the card level, whose
|
||
/// default `doc.text` sits inside `SymbolPickerCatalog.defaultSet`), and fall one well short of
|
||
/// full when it wasn't (board and lane) — a quieter outcome than a grid that overflows its own
|
||
/// 6×6 cap.
|
||
private var curatedWells: [SymbolPickerWell] {
|
||
let isDefaultSelected = current.map { !ItemSymbol.exists($0) } ?? true
|
||
var wells = [SymbolPickerWell(
|
||
id: 0,
|
||
name: fallback,
|
||
label: "Default (\(fallback))",
|
||
isSelected: isDefaultSelected,
|
||
isDefault: true
|
||
)]
|
||
let trailing = symbols.filter { $0 != fallback }
|
||
for (index, name) in trailing.prefix(SymbolPickerLayout.columns * SymbolPickerLayout.rows - 1).enumerated() {
|
||
wells.append(SymbolPickerWell(
|
||
id: index + 1,
|
||
name: name,
|
||
label: name,
|
||
isSelected: current == name,
|
||
isDefault: false
|
||
))
|
||
}
|
||
return wells
|
||
}
|
||
|
||
private func matchWells(_ matches: [String]) -> [SymbolPickerWell] {
|
||
matches.enumerated().map { index, name in
|
||
SymbolPickerWell(id: index, name: name, label: name, isSelected: current == name, isDefault: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Wells
|
||
|
||
/// One well in either grid: what it draws, what it is called, and whether it is the leading default.
|
||
private struct SymbolPickerWell: Identifiable {
|
||
let id: Int
|
||
let name: String
|
||
let label: String
|
||
let isSelected: Bool
|
||
/// Whether this is the leading "no override" well — drawn quieter (`StyleWellFace`'s
|
||
/// `.defaultSymbol` treatment) so "no symbol set" and "this symbol set" read differently at a
|
||
/// glance, and selected by `onSelect(nil)` rather than `onSelect(well.name)`.
|
||
let isDefault: Bool
|
||
}
|
||
|
||
/// One well's face: the glyph, tinted by whether it is the default. `StyleEditor.swift`'s
|
||
/// `StyleWellFace` already draws this exact shape, but as a `private` type it is not this file's to
|
||
/// reach — a small sibling here, rather than widening that file's access for one caller outside it.
|
||
private struct SymbolWellFace: View {
|
||
|
||
let name: String
|
||
let isDefault: Bool
|
||
let size: CGFloat
|
||
/// The glyph's font size — set explicitly (`SymbolPickerLayout.glyphPointSize`) rather than
|
||
/// inherited, since the grid's enlargement lives in the font, not the frame.
|
||
let glyphPointSize: CGFloat
|
||
|
||
var body: some View {
|
||
Image(systemName: ItemSymbol.exists(name) ? name : "questionmark.square.dashed")
|
||
.font(.system(size: glyphPointSize))
|
||
.foregroundStyle(isDefault ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary))
|
||
.frame(width: size, height: size)
|
||
}
|
||
}
|
||
|
||
/// One grid of wells: Tab-reachable buttons, arrow-navigable as a grid — `StyleWellGrid`'s pattern,
|
||
/// mirrored rather than shared for the same reason `SymbolWellFace` is its own type. The duplication
|
||
/// is small (one `move(_:)` handler) and the alternative — exporting `StyleWellGrid` generically out
|
||
/// of the style editor — would widen a file whose whole point is staying anchor-agnostic to a second,
|
||
/// unrelated caller.
|
||
private struct SymbolWellGrid: View {
|
||
|
||
let wells: [SymbolPickerWell]
|
||
let layout: SymbolPickerLayout
|
||
let onSelect: (SymbolPickerWell) -> 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: SymbolPickerLayout.columns
|
||
),
|
||
spacing: layout.wellSpacing
|
||
) {
|
||
ForEach(wells) { well in
|
||
Button {
|
||
onSelect(well)
|
||
} label: {
|
||
SymbolWellFace(
|
||
name: well.name,
|
||
isDefault: well.isDefault,
|
||
size: layout.wellSide,
|
||
glyphPointSize: layout.glyphPointSize
|
||
)
|
||
.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)
|
||
}
|
||
}
|
||
|
||
private func selectionRing(_ isSelected: Bool) -> some View {
|
||
RoundedRectangle(cornerRadius: max(1, (layout.wellSide * 0.25).rounded()))
|
||
.strokeBorder(
|
||
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
|
||
)
|
||
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
|
||
}
|
||
|
||
/// One step per press, clamped at the ends — `StyleWellGrid.move(_:)`'s rule, restated for this
|
||
/// grid's own fixed column count.
|
||
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
||
let delta: Int
|
||
switch key {
|
||
case .leftArrow: delta = -1
|
||
case .rightArrow: delta = 1
|
||
case .upArrow: delta = -SymbolPickerLayout.columns
|
||
case .downArrow: delta = SymbolPickerLayout.columns
|
||
default: return .ignored
|
||
}
|
||
let current = focused ?? 0
|
||
let next = min(max(0, current + delta), wells.count - 1)
|
||
focused = next
|
||
return .handled
|
||
}
|
||
}
|
||
|
||
// MARK: - The colour row
|
||
|
||
/// One well in the colour grid: a palette name, or `nil` for the leading None.
|
||
private struct SymbolColorWell: Identifiable {
|
||
let id: Int
|
||
/// The palette name this well writes, or `nil` for the None well — the removal.
|
||
let name: String?
|
||
let label: String
|
||
let isSelected: Bool
|
||
}
|
||
|
||
/// The tint grid under the symbol grid — the leading **None** well and
|
||
/// `SymbolPickerCatalog.colorSet`'s eleven tints, 4×3 (the board popover's colour row, 2026-08-07;
|
||
/// grown by a row with the palette on 2026-08-09).
|
||
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
|
||
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
|
||
/// four-column re-division of the symbol grid's width) fits neither.
|
||
private struct SymbolColorGrid: View {
|
||
|
||
/// The committed tint as written, or `nil` when the key is absent.
|
||
let current: String?
|
||
let layout: SymbolPickerLayout
|
||
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.colorWellWidth), spacing: layout.wellSpacing),
|
||
count: SymbolPickerLayout.colorColumns
|
||
),
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// The None well leads, selected whenever no tint would render — a missing key and an
|
||
/// unresolvable value read the same way here, `ItemSymbol.name(_:fallback:)`'s lenient rule
|
||
/// turned on the colour dimension.
|
||
private var wells: [SymbolColorWell] {
|
||
let isNoneSelected = current.map { Palette.color(named: $0) == nil } ?? true
|
||
var wells = [SymbolColorWell(id: 0, name: nil, label: "No Color", isSelected: isNoneSelected)]
|
||
for (index, name) in SymbolPickerCatalog.colorSet.enumerated() {
|
||
wells.append(SymbolColorWell(id: index + 1, name: name, label: name, isSelected: current == name))
|
||
}
|
||
return wells
|
||
}
|
||
|
||
/// A colour swatch, always stroked (`chalk`'s lesson from the style editor's wells: a pale
|
||
/// swatch with no border is an invisible control), with the corner-to-corner slash standing in
|
||
/// for a colour on the None well — Finder's own vocabulary for "there isn't one".
|
||
private func swatch(_ color: Color?) -> some View {
|
||
RoundedRectangle(cornerRadius: cornerRadius)
|
||
.fill(color ?? Color(nsColor: .textBackgroundColor))
|
||
.overlay {
|
||
if color == nil {
|
||
ColorNoneStrike(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.colorWellWidth, height: layout.wellSide)
|
||
}
|
||
|
||
private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.25).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(_:)`, at this grid's own four columns.
|
||
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
|
||
let delta: Int
|
||
switch key {
|
||
case .leftArrow: delta = -1
|
||
case .rightArrow: delta = 1
|
||
case .upArrow: delta = -SymbolPickerLayout.colorColumns
|
||
case .downArrow: delta = SymbolPickerLayout.colorColumns
|
||
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`, restated as a
|
||
/// sibling for `SymbolWellFace`'s reason: that type is private to a file whose whole point is
|
||
/// staying anchor-agnostic, and one two-point path is cheaper than widening it.
|
||
private struct ColorNoneStrike: 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
|
||
}
|
||
}
|