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.
This commit is contained in:
@@ -309,9 +309,28 @@ struct BoardInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Title")
|
||||
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)
|
||||
|
||||
Divider()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user