From 73698cd77b7d61fe571afb1a8f420cd23773bea2 Mon Sep 17 00:00:00 2001 From: rzen Date: Thu, 6 Aug 2026 21:18:35 -0400 Subject: [PATCH] =?UTF-8?q?A=20reusable=20symbol=20picker=20=E2=80=94=20th?= =?UTF-8?q?e=20board's=20glyph=20joins=20its=20name=20in=20the=20info=20po?= =?UTF-8?q?pover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SymbolPicker: one well at rest, a 6x6 curated grid in a popover (leading well = the level default, clearing the key), and an optional search over the OS's full symbol inventory read from CoreGlyphs metadata. Geometry font-derived off StyleEditorLayout's base, grid enlarged by a deliberate 1.3x. Wired inline with the rename field in the board info popover through the StyleCommand funnel; the Title header retires. Curated set and the search's standing vs 03's full-browser refusal await ratification. --- Kanban/UI/Board/BoardInfoPopover.swift | 23 +- Kanban/UI/SymbolPicker.swift | 467 +++++++++++++++++++++++++ KanbanTests/SymbolPickerTests.swift | 87 +++++ 3 files changed, 575 insertions(+), 2 deletions(-) create mode 100644 Kanban/UI/SymbolPicker.swift create mode 100644 KanbanTests/SymbolPickerTests.swift diff --git a/Kanban/UI/Board/BoardInfoPopover.swift b/Kanban/UI/Board/BoardInfoPopover.swift index 94dc9e4..c22804e 100644 --- a/Kanban/UI/Board/BoardInfoPopover.swift +++ b/Kanban/UI/Board/BoardInfoPopover.swift @@ -309,8 +309,27 @@ struct BoardInfoView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 6) { - sectionHeader("Title") - BoardRenameField(store: store) + HStack(spacing: 6) { + // The board's own icon, inline with its name — the same field the embedded style + // editor's symbol section below writes, offered here too since a board's identity + // is its name *and* its glyph together (03-board-ui.md § Styling ▸ Controls). No + // `undo:` — the board popover has none of its own, so this reaches the board's + // stack exactly as the embedded editor's writes do. + SymbolPicker( + current: store.snapshot.icon.value, + fallback: ItemSymbol.board, + onSelect: { name in + StyleCommand.apply( + icon: name.map { StyleChange.set($0) } ?? .remove, + to: .board, + in: store, + recents: recents + ) + } + ) + .disabled(!store.acceptsBoardMutations) + BoardRenameField(store: store) + } } .padding(inset) diff --git a/Kanban/UI/SymbolPicker.swift b/Kanban/UI/SymbolPicker.swift new file mode 100644 index 0000000..50324cd --- /dev/null +++ b/Kanban/UI/SymbolPicker.swift @@ -0,0 +1,467 @@ +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 + } +} diff --git a/KanbanTests/SymbolPickerTests.swift b/KanbanTests/SymbolPickerTests.swift new file mode 100644 index 0000000..f9a6047 --- /dev/null +++ b/KanbanTests/SymbolPickerTests.swift @@ -0,0 +1,87 @@ +import Testing +@testable import Kanban + +/// **The symbol picker's pure seams** (03-board-ui.md § Styling ▸ Controls, the general-purpose +/// picker `SymbolPicker.swift` builds beside the style editor's own curated grid): the curated +/// default set, the full-catalog loader and its cache, and the search filter's AND semantics. +/// SwiftUI rendering — the well grid, the popover's arrow-key navigation — is deliberately untested, +/// exactly as `StyleEditor.swift`'s own wells are. +@Suite("SymbolPicker ▸ the curated default set") +struct SymbolPickerCatalogDefaultSetTests { + + @Test("Exactly 36 entries, all unique") + func shape() { + #expect(SymbolPickerCatalog.defaultSet.count == 36) + #expect(Set(SymbolPickerCatalog.defaultSet).count == SymbolPickerCatalog.defaultSet.count) + } + + @Test("Every entry is one this system can actually draw — pins the list against typos") + func everyNameResolves() { + let missing = SymbolPickerCatalog.defaultSet.filter { !ItemSymbol.exists($0) } + #expect(missing.isEmpty, "unknown SF Symbol names: \(missing)") + #expect(SymbolPickerCatalog.available.count == SymbolPickerCatalog.defaultSet.count) + } +} + +@Suite("SymbolPicker ▸ filter") +struct SymbolPickerFilterTests { + + @Test("An empty or whitespace-only query returns the input unchanged") + func emptyQueryIsANoOp() { + let symbols = ["star", "flag", "heart"] + #expect(SymbolPickerCatalog.filter("", in: symbols) == symbols) + #expect(SymbolPickerCatalog.filter(" ", in: symbols) == symbols) + #expect(SymbolPickerCatalog.filter("\t\n", in: symbols) == symbols) + } + + @Test("A single token matches case-insensitively, as a substring") + func singleTokenSubstring() { + let symbols = ["star", "star.fill", "flag", "flag.checkered"] + #expect(SymbolPickerCatalog.filter("star", in: symbols) == ["star", "star.fill"]) + #expect(SymbolPickerCatalog.filter("STAR", in: symbols) == ["star", "star.fill"]) + #expect(SymbolPickerCatalog.filter("Fla", in: symbols) == ["flag", "flag.checkered"]) + } + + @Test("Multiple tokens are an AND — every token must appear somewhere in the name") + func multiTokenIsAnAnd() { + let symbols = ["wrench.and.screwdriver", "screwdriver", "wrench"] + #expect(SymbolPickerCatalog.filter("wrench screw", in: symbols) == ["wrench.and.screwdriver"]) + #expect(SymbolPickerCatalog.filter("screw wrench", in: symbols) == ["wrench.and.screwdriver"]) + } + + @Test("Input order is preserved") + func orderPreserved() { + let symbols = ["zebra.star", "apple.star", "mango.star"] + #expect(SymbolPickerCatalog.filter("star", in: symbols) == symbols) + } + + @Test("No match returns an empty array") + func noMatchIsEmpty() { + #expect(SymbolPickerCatalog.filter("xyzzy-nonexistent", in: ["star", "flag"]).isEmpty) + } +} + +@Suite("SymbolPicker ▸ the full catalog") +struct SymbolPickerFullCatalogTests { + + @Test("The default path returns a sorted, unique, non-empty list containing well-known names") + func defaultPathLoads() { + let catalog = SymbolPickerCatalog.fullCatalog() + #expect(!catalog.isEmpty) + #expect(catalog == catalog.sorted()) + #expect(Set(catalog).count == catalog.count) + #expect(catalog.contains("star")) + #expect(catalog.contains("folder")) + } + + @Test("Two calls against the default path return identical results — the cache is coherent") + func cacheIsCoherent() { + #expect(SymbolPickerCatalog.fullCatalog() == SymbolPickerCatalog.fullCatalog()) + } + + @Test("A nonexistent bundle path falls back to the merged curated set, sorted and unique") + func nonexistentPathFallsBack() { + let expected = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.all).sorted() + #expect(SymbolPickerCatalog.fullCatalog(bundlePath: "/nonexistent") == expected) + } +}