Files
lanework/Kanban/UI/SymbolPicker.swift
T
rzen 7ac34651a2 The board's symbol takes a tint — a 4×2 colour row under the picker's glyphs, and the glyph itself moves into the titlebar
iconColor stops being hand-written-only (user-ruled, superseding 03's
"schema yes, control no"): it rides StyleCommand.apply → applyStyle as
the third styled dimension — per-dimension no-op skip, one bracket, one
history step, ExpectedField.iconColor for staleness. The SymbolPicker
grows an opt-in colour row (leading None plus seven Palette.foregrounds
hues, None removes the key); the board popover is its one caller. The
window-title widget now draws the board's resolved glyph in that tint
beside the name. Doc realignment filed on the Redesign board (Minor).

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-08-07 18:36:49 -04:00

649 lines
31 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) }
/// The colour row's seven tints — `Palette.foregrounds`' hues, minus the four grayscale steps
/// (a symbol's *tint* wants colour; "no tint" is the None well's job, not a gray's) and minus
/// `deep-cool-granite`, the mutedest of the eight, dropped so the row plus its leading None
/// fills the 4×2 grid exactly. Palette names, not hexes, exactly as the style editor's wells
/// write them.
static let colorSet: [String] = [
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
"light-teal", "deep-sky-blue", "pale-violet",
]
/// 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 colour row's own shape — 4×2, the leading None plus `SymbolPickerCatalog.colorSet`'s
/// seven tints.
static let colorColumns = 4
static let colorRows = 2
/// 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
/// A colour well's width: the symbol grid's width re-divided into four columns, so the colour
/// rows sit flush under the symbol grid rather than introducing a second width. Height stays
/// `wellSide` — the swatch is wide, not tall.
var colorWellWidth: 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,
colorWellWidth: ((gridWidth - spacing * CGFloat(colorColumns - 1)) / CGFloat(colorColumns)).rounded(.down),
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
/// The committed tint (`iconColor`), or `nil` for "no tint" — read only when `onSelectColor` is
/// wired, since a picker with no colour row has no tint to state.
var currentColor: String? = nil
/// The colour row's contract, `onSelect`'s shape one dimension over: a palette name to set, or
/// `nil` to clear the tint. **`nil` here means no colour row at all** — the grid is opt-in, so
/// the callers that wanted a symbol picker keep getting exactly one.
var onSelectColor: ((String?) -> Void)? = nil
@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
}
/// The at-rest well's tint, or `nil` for the standard one — `Palette`'s lenient rule, gated on
/// the colour row being offered at all.
private var resolvedTint: AnyShapeStyle? {
guard onSelectColor != nil, let currentColor, let color = Palette.color(named: currentColor) else { return nil }
return AnyShapeStyle(color)
}
var body: some View {
let layout = SymbolPickerLayout.metrics(bodyPointSize: pointSize)
Button {
isPresented = true
} label: {
Image(systemName: resolvedName)
.imageScale(.medium)
// The tint the board actually renders with, on the well that states the board's
// glyph — shown only where the picker offers the colour row, and lenient exactly
// like the glyph itself: an unresolvable value tints nothing.
.foregroundStyle(resolvedTint ?? AnyShapeStyle(.primary))
.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
},
currentColor: currentColor,
onSelectColor: onSelectColor.map { select in
{ name in
select(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
var currentColor: String? = nil
/// `nil` is "no colour row" — `SymbolPicker.onSelectColor`'s opt-in, passed through.
var onSelectColor: ((String?) -> Void)? = nil
@State private var query = ""
var body: some View {
VStack(alignment: .leading, spacing: layout.searchSpacing) {
if searchable {
searchField
}
resultBody
// The colour row rides below whichever grid is up — a search narrows the symbols, not
// the tints, so the row keeps standing where the eye left it.
if let onSelectColor {
Divider()
SymbolColorGrid(current: currentColor, layout: layout, onSelect: onSelectColor)
}
}
.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
}
}
// MARK: - The colour row
/// One well in the colour grid: a palette name, or `nil` for the leading None.
private struct SymbolColorWell: Identifiable {
let id: Int
/// The palette name this well writes, or `nil` for the None well — the removal.
let name: String?
let label: String
let isSelected: Bool
}
/// The tint grid under the symbol grid — the leading **None** well and
/// `SymbolPickerCatalog.colorSet`'s seven tints, 4×2 (the board popover's colour row, 2026-08-07).
/// `StyleWellGrid`'s pattern one more time, and `SymbolWellGrid`'s reason for restating it: the
/// style editor's grids are that file's own, and this row's shape (wide swatches on a fixed
/// four-column re-division of the symbol grid's width) fits neither.
private struct SymbolColorGrid: View {
/// The committed tint as written, or `nil` when the key is absent.
let current: String?
let layout: SymbolPickerLayout
let onSelect: (String?) -> Void
@FocusState private var focused: Int?
@Environment(\.colorSchemeContrast) private var contrast
var body: some View {
LazyVGrid(
columns: Array(
repeating: GridItem(.flexible(minimum: layout.colorWellWidth), spacing: layout.wellSpacing),
count: SymbolPickerLayout.colorColumns
),
spacing: layout.wellSpacing
) {
ForEach(wells) { well in
Button {
onSelect(well.name)
} label: {
swatch(well.name.flatMap(Palette.color(named:)))
.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)
}
}
/// The None well leads, selected whenever no tint would render — a missing key and an
/// unresolvable value read the same way here, `ItemSymbol.name(_:fallback:)`'s lenient rule
/// turned on the colour dimension.
private var wells: [SymbolColorWell] {
let isNoneSelected = current.map { Palette.color(named: $0) == nil } ?? true
var wells = [SymbolColorWell(id: 0, name: nil, label: "No Color", isSelected: isNoneSelected)]
for (index, name) in SymbolPickerCatalog.colorSet.enumerated() {
wells.append(SymbolColorWell(id: index + 1, name: name, label: name, isSelected: current == name))
}
return wells
}
/// A colour swatch, always stroked (`chalk`'s lesson from the style editor's wells: a pale
/// swatch with no border is an invisible control), with the corner-to-corner slash standing in
/// for a colour on the None well — Finder's own vocabulary for "there isn't one".
private func swatch(_ color: Color?) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(color ?? Color(nsColor: .textBackgroundColor))
.overlay {
if color == nil {
ColorNoneStrike(inset: max(1, (layout.wellSide * 0.15).rounded()))
.stroke(.secondary, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
}
}
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(.separator, lineWidth: Accommodations.borderWidth(1, contrast: contrast))
)
.frame(width: layout.colorWellWidth, height: layout.wellSide)
}
private var cornerRadius: CGFloat { max(1, (layout.wellSide * 0.25).rounded()) }
private func selectionRing(_ isSelected: Bool) -> some View {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
lineWidth: Accommodations.borderWidth(2, contrast: contrast)
)
.padding(-Accommodations.borderWidth(2, contrast: contrast) / 2)
}
/// `SymbolWellGrid.move(_:)`, at this grid's own four columns.
private func move(_ key: KeyEquivalent) -> KeyPress.Result {
let delta: Int
switch key {
case .leftArrow: delta = -1
case .rightArrow: delta = 1
case .upArrow: delta = -SymbolPickerLayout.colorColumns
case .downArrow: delta = SymbolPickerLayout.colorColumns
default: return .ignored
}
let current = focused ?? 0
let next = min(max(0, current + delta), wells.count - 1)
focused = next
return .handled
}
}
/// The None well's corner-to-corner slash — `StyleEditor.swift`'s `NoValueStrike`, restated as a
/// sibling for `SymbolWellFace`'s reason: that type is private to a file whose whole point is
/// staying anchor-agnostic, and one two-point path is cheaper than widening it.
private struct ColorNoneStrike: Shape {
let inset: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX + inset, y: rect.maxY - inset))
path.addLine(to: CGPoint(x: rect.maxX - inset, y: rect.minY + inset))
return path
}
}