The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover
The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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<String> = ["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<String>()
|
||||
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] ?? []) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user