import Foundation import SwiftUI /// **A reusable SF Symbol picker** — a single well showing the resolved symbol, opening a curated /// grid with a search escape hatch (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 … No full-browser escape hatch in-app; the raw file is the escape hatch"). `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.all` is grouped by what a *board item* is (status/flow, containers, people…) — /// this control has no board item in mind, so `SymbolPickerCatalog.defaultSet` is a smaller, /// ungrouped 36 chosen for the general "boards and projects" case instead. The two lists are free to /// diverge; nothing here reads the other. /// /// ### 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". // 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 the /// style editor's kanban-item 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.all`'s 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`'s rule, mirrored: a curated /// list is a convenience, never a claim about the running system. static var available: [String] { defaultSet.filter(ItemSymbol.exists) } /// 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.all).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 /// button keeps the unscaled side (`restSide`) — it sits inline with a text field and matches that /// field's height, not the grid's. 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 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 at-rest button's side — the unscaled base, matched to the style editor's wells and to /// the text-field height the button sits beside. var restSide: CGFloat /// 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 /// 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 baseSide = StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize) let side = (baseSide * 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( restSide: baseSide, glyphPointSize: (bodyPointSize * gridScale).rounded(), wellSide: side, wellSpacing: spacing, searchSpacing: spacing, contentPadding: padding, gridWidth: gridWidth, gridHeight: gridHeight, popoverWidth: (gridWidth + padding * 2).rounded() ) } } // MARK: - The control /// A single symbol well that opens a curated grid — the reusable primitive `03-board-ui.md`'s /// full-browser refusal ("the raw file is the escape hatch") leaves room for: not a new in-app way to /// hand-edit `icon`, but a control any 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. /// /// **View-local state only** — the popover's presented flag lives here, its search text lives with /// the popover content. Nothing about a store, an undo stack, or a target set is known to this type; /// `onSelect` is the whole of its contract with a caller, exactly as a `Picker`'s `selection` binding /// would be. 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 @State private var isPresented = false private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } /// What the well actually draws — `current` if this system can resolve it, `fallback` otherwise. /// The same lenient rule `ItemSymbol.name(_:fallback:)` states for a `FieldValue`, restated here /// because this control's `current` is already a plain optional string by the time it arrives. private var resolvedName: String { if let current, ItemSymbol.exists(current) { return current } return fallback } var body: some View { let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize) Button { isPresented = true } label: { Image(systemName: resolvedName) .imageScale(.medium) .frame(width: layout.restSide, height: layout.restSide) } .buttonStyle(.bordered) .help("Symbol") .accessibilityLabel("Symbol") .accessibilityValue(resolvedName) .popover(isPresented: $isPresented, arrowEdge: .bottom) { SymbolPickerPopoverContent( current: current, fallback: fallback, symbols: symbols, searchable: searchable, layout: layout, onSelect: { name in onSelect(name) isPresented = false } ) } } } // 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 @State private var query = "" var body: some View { VStack(alignment: .leading, spacing: layout.searchSpacing) { if searchable { searchField } resultBody } .padding(layout.contentPadding) .frame(width: layout.popoverWidth) } 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 } }