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.
468 lines
23 KiB
Swift
468 lines
23 KiB
Swift
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
|
||
}
|
||
}
|