import Foundation /// **The OS's own SF Symbols index, read off disk** — the categories, their canonical order, and the /// search keywords behind them. What `SymbolBrowserPanel` browses. /// /// ### Why this is a read and not a hand-written list /// /// There is no public API that enumerates SF Symbols, let alone their categories, so the obvious plan /// is a curated constant. But `SymbolPickerCatalog.fullCatalog()` already establishes the better one: /// the data ships with the OS as plain property lists inside `CoreGlyphs.bundle`, and that file /// already reads one of them (`name_availability.plist`) for exactly this reason — a hand-written /// inventory is "a hand-written guess that might be stale, and only guesses need checking", where the /// bundle *is* the running system's answer. This extends that read to the four siblings that make a /// real browser possible: /// /// - `categories.plist` — the categories, in **Apple's own display order**, each with a /// representative glyph. That ordering is a design decision Apple already made and this app has no /// better one, so it is taken verbatim. /// - `symbol_categories.plist` — every symbol's category memberships. A symbol may sit in several, /// and does; the browser lists it under each. /// - `symbol_order.plist` — the canonical ordering the SF Symbols app itself displays in, which is /// grouped by shape and family. Sorting a category alphabetically instead would scatter /// `arrow.up`, `arrow.down` and `arrow.left` across the grid; this keeps families together. /// - `symbol_search.plist` — per-symbol search keywords. This is what makes "delete" find `trash` /// and "password" find `key.slash`, which a name-substring search cannot. /// /// ### The three rulings taken on that data (2026-08-09) /// /// **Five categories are dropped** — `all`, `whatsnew`, `variable`, `multicolor`, `draw`. They /// classify *rendering behaviour* or *release vintage*, not subject matter. "Multicolor" as a sibling /// of "Nature" answers a question nobody browsing for a lane glyph is asking, and `whatsnew` is a /// category that means something different every autumn. /// /// **Trademark-restricted symbols are excluded.** `symbol_restrictions.strings` names about six /// hundred glyphs — the Apple logo, iCloud, FaceTime, Apple Intelligence — each carrying "may only be /// used to refer to" its product. Offering them in a general glyph browser invites a misuse the app /// can prevent for the price of one set lookup. A board that already has one on disk still renders it: /// this narrows what the *picker offers*, never what the renderer honours, which is the same split /// `03-board-ui.md` draws with "curated in-app, unlimited on disk". /// /// **`indices` stays**, numerals in every script and all. Letters and digits in circles are genuinely /// useful for numbered lanes, `symbol_order` puts the Latin ones first, and dropping the Arabic-Indic /// and Devanagari variants to tidy the grid would be the worse call. /// /// ### What is *not* filtered /// /// Names are **not** run through `ItemSymbol.exists`. `fullCatalog()`'s reasoning applies unchanged — /// the plists are the running OS's inventory, so checking them against the running OS is cost for an /// answer already given, and thousands of `NSImage` lookups per keystroke is a real cost. (Verified /// once by hand at the time of writing: every name in `symbol_categories.plist` also appears in /// `name_availability.plist`, zero misses.) The *curated* lists remain filtered, because those are /// the guesses. enum SymbolCatalog { // MARK: - Types /// One browsable category: what it is called, what it looks like in a sidebar, and what is in it. struct Category: Identifiable, Sendable, Equatable { /// The OS's own key — `objectsandtools`. Stable across releases and the browser's selection /// token, which is why selection survives a reload where a localized title would not. let key: String /// The sidebar's label. let title: String /// The representative glyph `categories.plist` names for it. let icon: String /// Its members, in `symbol_order.plist`'s canonical order. let symbols: [String] var id: String { key } } /// Everything one load produces — kept as one value so the loader is a pure function of a bundle /// path and the test suite can drive it against a path that isn't there. struct Contents: Sendable, Equatable { let categories: [Category] /// Every offered symbol, canonically ordered — the browser's "All Symbols". let allSymbols: [String] /// Symbol → its search keywords. Absent for most symbols; the search falls back to the name. let keywords: [String: [String]] } // MARK: - Loading /// Where the OS keeps its SF Symbols metadata — read-only system data, present on every Mac that /// ships SF Symbols at all. The same bundle `SymbolPickerCatalog` reads. static let defaultBundlePath = "/System/Library/CoreServices/CoreGlyphs.bundle" /// Categories that classify rendering behaviour or release vintage rather than subject matter — /// see this type's own doc comment. static let excludedCategoryKeys: Set = ["all", "whatsnew", "variable", "multicolor", "draw"] /// The category titles, which are the one thing here that **is** hand-written: the bundle ships /// no localized names for its keys, only the keys. Twenty-seven pairs, and `title(forKey:)` falls /// back to a title-cased key for one a future OS adds — a new category shows up in the browser /// reading a little awkwardly rather than not showing up at all. static let categoryTitles: [String: String] = [ "accessibility": "Accessibility", "arrows": "Arrows", "automotive": "Automotive", "cameraandphotos": "Camera & Photos", "commerce": "Commerce", "communication": "Communication", "connectivity": "Connectivity", "devices": "Devices", "editing": "Editing", "fitness": "Fitness", "gaming": "Gaming", "health": "Health", "home": "Home", "human": "Human", "indices": "Indices", "keyboard": "Keyboard", "maps": "Maps", "math": "Math", "media": "Media", "nature": "Nature", "objectsandtools": "Objects & Tools", "privacyandsecurity": "Privacy & Security", "shapes": "Shapes", "textformatting": "Text Formatting", "time": "Time", "transportation": "Transportation", "weather": "Weather", ] /// The default path's contents, loaded once. A `static let` rather than a `lazy var`, for /// `SymbolPickerCatalog.cachedFullCatalog`'s reason: the load is synchronous and the result is /// immutable and `Sendable`, so Swift's own thread-safe one-time global initialization is the /// whole of the cache, with no actor to hang it off. private static let cached: Contents = load(bundlePath: defaultBundlePath) /// The contents for `bundlePath` — cached at the real system location, reloaded anywhere else, /// which is `fullCatalog(bundlePath:)`'s bargain and the honest cost of asking a question the /// cache was never built to answer. static func contents(bundlePath: String = defaultBundlePath) -> Contents { bundlePath == defaultBundlePath ? cached : load(bundlePath: bundlePath) } static var categories: [Category] { cached.categories } static var allSymbols: [String] { cached.allSymbols } /// The title for `key` — the table above, else the key title-cased on its word boundaries as best /// they can be guessed from a lowercase run (there are none, so `objectsandtools` would come back /// as "Objectsandtools"; the table exists precisely so that never happens for a key we know). static func title(forKey key: String) -> String { categoryTitles[key] ?? key.prefix(1).uppercased() + key.dropFirst() } /// The load, and its one fallback. /// /// A bundle that won't open, a resource that isn't there, or a plist whose shape this reader /// cannot make sense of all read the same way — as "no inventory" — rather than as several /// failure modes to chase, which is `SymbolPickerCatalog.load`'s posture and its reason. The /// fallback is a single synthetic category over the app's own curated sets, so the browser always /// has something to show and something to search even on a system whose metadata this cannot /// read. static func load(bundlePath: String) -> Contents { guard let bundle = Bundle(path: bundlePath) else { return fallback() } // A `.strings` file, but a binary plist inside — a dictionary of name → the sentence // explaining what the trademark permits. Only the keys matter here. let restricted = Set((plist(bundle, "symbol_restrictions", "strings") as? [String: Any] ?? [:]).keys) let order = plist(bundle, "symbol_order", "plist") as? [String] ?? [] let membership = plist(bundle, "symbol_categories", "plist") as? [String: [String]] ?? [:] let keywords = plist(bundle, "symbol_search", "plist") as? [String: [String]] ?? [:] let ordered = plist(bundle, "categories", "plist") as? [[String: String]] ?? [] guard !membership.isEmpty, !ordered.isEmpty else { return fallback() } // `symbol_order` is the display order; a symbol missing from it (there are a handful) sorts // after everything that is in it, alphabetically among its peers, rather than to the front. var rank: [String: Int] = [:] rank.reserveCapacity(order.count) for (index, name) in order.enumerated() where rank[name] == nil { rank[name] = index } func canonical(_ names: [String]) -> [String] { names.sorted { lhs, rhs in switch (rank[lhs], rank[rhs]) { case let (left?, right?): left < right case (_?, nil): true case (nil, _?): false case (nil, nil): lhs < rhs } } } var buckets: [String: [String]] = [:] var offered = Set() for (name, keys) in membership where !restricted.contains(name) { var isOffered = false for key in keys where !excludedCategoryKeys.contains(key) { buckets[key, default: []].append(name) isOffered = true } // A symbol whose every category was dropped is not offered at all — it would be // unreachable in the sidebar and would only ever surface from a search, which is a // confusing half-presence. if isOffered { offered.insert(name) } } let categories: [Category] = ordered.compactMap { entry in guard let key = entry["key"], !excludedCategoryKeys.contains(key), let symbols = buckets[key], !symbols.isEmpty else { return nil } return Category( key: key, title: title(forKey: key), icon: entry["icon"] ?? "square.grid.2x2", symbols: canonical(symbols) ) } guard !categories.isEmpty else { return fallback() } return Contents( categories: categories, allSymbols: canonical(Array(offered)), keywords: keywords ) } private static func plist(_ bundle: Bundle, _ name: String, _ type: String) -> Any? { guard let path = bundle.path(forResource: name, ofType: type), let data = FileManager.default.contents(atPath: path) else { return nil } return try? PropertyListSerialization.propertyList(from: data, format: nil) } /// One synthetic category over the app's own curated vocabulary — see `load(bundlePath:)`. private static func fallback() -> Contents { let symbols = Set(SymbolPickerCatalog.defaultSet + CuratedSymbols.combined).sorted() return Contents( categories: [Category(key: "all", title: "All Symbols", icon: "square.grid.2x2", symbols: symbols)], allSymbols: symbols, keywords: [:] ) } // MARK: - Search /// Whether `name` (with `keywords`) matches `query` — **pure**, so the AND semantics and the /// keyword reach are assertable without a panel on screen. /// /// A query splits into whitespace-separated tokens and **every** token must appear, as a /// case-insensitive substring, either in the name or in one of the keywords. `SymbolPickerCatalog. /// filter`'s rule, one dimension wider: that one searches names alone, which is right for a /// thirty-six-glyph curated grid and useless against nine thousand, where the word a user reaches /// for ("delete", "password", "wifi") is frequently not in the name at all. /// /// Tokens are matched independently, so `"arrow down"` finds `arrow.down` *and* anything keyworded /// both — the Spotlight-ish behaviour a search field is expected to have. static func matches(query tokens: [String], name: String, keywords: [String]) -> Bool { guard !tokens.isEmpty else { return true } let lowered = name.lowercased() let loweredKeywords = keywords.map { $0.lowercased() } return tokens.allSatisfy { token in lowered.contains(token) || loweredKeywords.contains { $0.contains(token) } } } /// `query` split into the lowercased tokens `matches(query:name:keywords:)` wants — trimmed /// first, and an empty result of that is "no query" rather than "match nothing", so a freshly /// opened search field shows everything. static func tokens(_ query: String) -> [String] { query.trimmingCharacters(in: .whitespacesAndNewlines) .split(whereSeparator: { $0.isWhitespace }) .map { $0.lowercased() } } /// `symbols` narrowed to those matching `query`, order preserved. static func search(_ query: String, in symbols: [String], keywords: [String: [String]]) -> [String] { let tokens = tokens(query) guard !tokens.isEmpty else { return symbols } return symbols.filter { matches(query: tokens, name: $0, keywords: keywords[$0] ?? []) } } }