Files
lanework/Kanban/UI/SymbolPicker.swift
T
rzen 73698cd77b A reusable symbol picker — the board's glyph joins its name in the info popover
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.
2026-08-06 21:18:35 -04:00

468 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}