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 } }