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