The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
831 lines
40 KiB
Swift
831 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
|
||
}
|
||
|
||
/// The width an unconstrained measuring pass falls back to — a face wide enough for the glyph to
|
||
/// sit in with room to read as a *field* rather than a button, plus the trigger. The normal case
|
||
/// is a finite proposal (a sidebar row's `.frame(width:)`), which this never has to stand in for.
|
||
@MainActor
|
||
private var defaultFaceWidth: CGFloat {
|
||
let metrics = ComboFieldMetrics.current
|
||
return (metrics.height * 2 + metrics.triggerWidth).rounded()
|
||
}
|
||
|
||
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 whatever width SwiftUI proposes, like any other control — `ColorComboView.sizeThatFits`'
|
||
/// 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 = defaultFaceWidth
|
||
}
|
||
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.
|
||
override func drawFace(in rect: NSRect) {
|
||
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.minX + metrics.facePaddingH,
|
||
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
|
||
}
|
||
}
|