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.
880 lines
45 KiB
Swift
880 lines
45 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
|
||
/// **A reusable SF Symbol picker** — a single clickable rectangle (`PickerRect.swift`'s shared
|
||
/// chrome) showing the resolved symbol, opening a curated grid popover on any click (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
|
||
///
|
||
/// This control used to be a single 20pt bordered square with one hit zone, then (2026-08-09) grew a
|
||
/// second, trigger, zone to rhyme with the colour combo's own two-zone shape — the face opening
|
||
/// `SymbolBrowserPanel` directly, the trigger popping this curated grid. The owner's 2026-08-10
|
||
/// reversal (Pipeline card 5004c540: "revert to previous look of the symbol picker, no combo trigger
|
||
/// … just a rectangle … clickable to show a popover with grid of symbols and colors") retired the
|
||
/// second zone outright: this is a `PickerRectControl` (`PickerRect.swift`) with exactly **one** hit
|
||
/// zone, and that one zone always opens the curated popover. `SymbolBrowserPanel`, the standalone
|
||
/// searchable/categorised browser, is reachable now **only** through the popover's own **More
|
||
/// Symbols…** row — which is also, as it always was, the only *keyboard* route to it, since a plain
|
||
/// rectangle has no second key that would mean "the other door."
|
||
///
|
||
/// The popover still carries the two rows that grammar implies: **More Symbols…** onto the browser,
|
||
/// and an **Other…** under the tint grid onto the Colors panel. The tint grid itself is 4×3, eleven
|
||
/// tints plus a leading None (`Palette.tints`) — unrelated to this card and unchanged by it.
|
||
|
||
// 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 `PickerRectMetrics`', shared with the
|
||
/// colour rectangle, which is what makes the two the same size. 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 single-rectangle symbol picker — 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 contract's API is byte for byte what it was through both the 2026-08-09 two-zone rework and
|
||
/// the 2026-08-10 reversal back to one — every existing caller kept working while the control
|
||
/// underneath changed shape twice.
|
||
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 {
|
||
SymbolGlyphView(
|
||
current: current,
|
||
fallback: fallback,
|
||
isEnabled: isEnabled,
|
||
currentColor: onSelectColor == nil ? nil : currentColor,
|
||
onClick: { 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 standalone browser, and the only
|
||
// way there at all now that the rectangle itself has no second zone or second key
|
||
// (`PickerRect.swift`'s grammar).
|
||
onBrowse: {
|
||
isPopoverPresented = false
|
||
openBrowser()
|
||
},
|
||
currentColor: currentColor,
|
||
onSelectColor: onSelectColor.map { select in
|
||
{ name in
|
||
isPopoverPresented = false
|
||
select(name)
|
||
}
|
||
},
|
||
// **Other…** under the tint grid — the colour rectangle's own row, one field over.
|
||
onPickCustomColor: onSelectColor.map { select in
|
||
{
|
||
isPopoverPresented = false
|
||
openTintPanel(select)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
|
||
/// **More Symbols…**'s door onto the standalone browser — the popover's only route there now
|
||
/// that the rectangle itself has no second zone of its own (`PickerRect.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 `SymbolGlyphControl` with its one zone wired to the caller's closure.
|
||
///
|
||
/// 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. Neither this view nor
|
||
/// `ColorSwatchPicker`'s own AppKit bridge carries a coordinator any more — the last thing that
|
||
/// needed one was the retired combo's `NSMenu` dropdown.
|
||
///
|
||
/// `.disabled(_:)` reaches it through `@Environment(\.isEnabled)` on `SymbolPicker` rather than a
|
||
/// parameter, since callers already spell the lock that way (`CardStyleSection`, `BoardInfoPopover`).
|
||
private struct SymbolGlyphView: 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 onClick: () -> 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) -> SymbolGlyphControl {
|
||
SymbolGlyphControl(frame: .zero)
|
||
}
|
||
|
||
func updateNSView(_ control: SymbolGlyphControl, context: Context) {
|
||
control.metrics = .current(.symbol)
|
||
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 closure closes over this
|
||
// struct's current values, and a stale one would open the popover for the field this picker
|
||
// used to be pointed at.
|
||
control.onClick = onClick
|
||
}
|
||
|
||
/// Obeys an explicit finite proposal when SwiftUI hands one over, else falls back to the control's
|
||
/// own intrinsic width — `ColorSwatchView.sizeThatFits`'s rule, restated so the two rectangles
|
||
/// answer a proposal identically and a caller that frames them alike gets two controls the same
|
||
/// size.
|
||
func sizeThatFits(_ proposal: ProposedViewSize, nsView: SymbolGlyphControl, 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
|
||
|
||
/// `PickerRectControl` with a **glyph** filling the whole face — the symbol half of the shared
|
||
/// chrome, and the whole of what is specific to symbols about it.
|
||
final class SymbolGlyphControl: PickerRectControl {
|
||
|
||
/// 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").
|
||
///
|
||
/// **Sized off the actual `rect`, not a pure metrics figure, since the evening
|
||
/// 2026-08-09 review.** That figure (now removed) was a pure derivation from the *intrinsic*
|
||
/// portrait width, but `sizeThatFits` obeys any finite width SwiftUI proposes, and the card
|
||
/// sidebar's row proposes real width — so the control renders wide while a glyph pinned at the
|
||
/// intrinsic figure stayed small, a glyph floating in visible dead space. That space *was* the
|
||
/// "padding" the owner's "reduce vertical size … on symbol picker by reducing padding" was naming,
|
||
/// so the fix reads the point size off the rect the face actually receives, via `glyphPointSize(
|
||
/// forFace:)` and `fittedSize(for:in:)` below — pure functions, not inlined here, so the sizing
|
||
/// rule is assertable without an actual draw.
|
||
///
|
||
/// **A sliver of padding returns, on purpose, in the 2026-08-10 follow-up.** The edge-to-edge
|
||
/// draw above went further than the owner's original "reduce padding" asked, once the rectangle
|
||
/// itself was squared up — a glyph with no breath at all reads as clipped rather than centred. Fix
|
||
/// is a small em-derived inset (`glyphInset(bodyPointSize:)`) subtracted from the face rect
|
||
/// *before* sizing and fitting — not a fixed pixel margin, so it scales with the rest of the
|
||
/// control's font-derived geometry, and not re-added to the drawn image's position, since the
|
||
/// inset rect is centred on the same midpoint as the uninset one.
|
||
override func drawFace(in rect: NSRect) {
|
||
guard rect.width > 0, rect.height > 0 else { return }
|
||
let inset = Self.glyphInset(bodyPointSize: CardWindowMetrics.bodyPointSize)
|
||
let faceRect = rect.insetBy(dx: inset, dy: inset)
|
||
guard faceRect.width > 0, faceRect.height > 0 else { return }
|
||
let name = ItemSymbol.exists(glyphName) ? glyphName : "questionmark.square.dashed"
|
||
let config = NSImage.SymbolConfiguration(pointSize: Self.glyphPointSize(forFace: faceRect), weight: .regular)
|
||
.applying(.init(paletteColors: [glyphTint ?? .labelColor]))
|
||
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)?
|
||
.withSymbolConfiguration(config)
|
||
else { return }
|
||
let size = Self.fittedSize(for: image.size, in: faceRect)
|
||
image.draw(in: NSRect(
|
||
x: rect.midX - size.width / 2,
|
||
y: rect.midY - size.height / 2,
|
||
width: size.width,
|
||
height: size.height
|
||
))
|
||
}
|
||
|
||
/// The glyph's target point size for a face rect — no inset subtracted here (the caller already
|
||
/// applied `glyphInset(bodyPointSize:)` to `rect` before handing it over), so this is simply
|
||
/// whichever of the rect's own two dimensions is smaller. SF Symbols already carry their own
|
||
/// internal margins, and the owner's evening review asked for the *dead space around* the glyph
|
||
/// gone, not a second margin layered on top of the system's own — the 2026-08-10 sliver is that
|
||
/// second margin reintroduced deliberately, at a much smaller, explicit figure.
|
||
static func glyphPointSize(forFace rect: NSRect) -> CGFloat {
|
||
min(rect.width, rect.height)
|
||
}
|
||
|
||
/// **"A sliver of padding around the symbol"** (owner's 2026-08-10 follow-up) — a small, visible
|
||
/// breath between the glyph and the face's own edge/border, not a ring wide enough to shrink the
|
||
/// glyph noticeably. `0.18` em sits at the middle of the owner's named 0.15–0.2 range, and — like
|
||
/// every other figure on this control — is a multiple of the body point size rather than a flat
|
||
/// pixel constant (10-accessibility.md's full-relative-scaling rule). A pure static function, not
|
||
/// inlined into `drawFace`, so the figure is assertable without an actual draw.
|
||
static func glyphInset(bodyPointSize: CGFloat) -> CGFloat {
|
||
max(1, (bodyPointSize * 0.18).rounded())
|
||
}
|
||
|
||
/// `imageSize` unchanged, unless it overshoots `rect` on either axis — a symbol configured at
|
||
/// `glyphPointSize(forFace:)` can still render wider (or, rarely, taller) than that on its long
|
||
/// axis, since SF Symbols are not all square glyphs. Scaled down proportionally so the drawn glyph
|
||
/// stays fully inside its face rather than clipping at the edges, and never scaled up: a glyph
|
||
/// smaller than its face on both axes is left exactly as configured.
|
||
static func fittedSize(for imageSize: NSSize, in rect: NSRect) -> NSSize {
|
||
guard rect.width > 0, rect.height > 0 else { return imageSize }
|
||
let overshoot = max(imageSize.width / rect.width, imageSize.height / rect.height)
|
||
guard overshoot > 1 else { return imageSize }
|
||
return NSSize(width: imageSize.width / overshoot, height: imageSize.height / overshoot)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|