The two pickers rhyme — one two-zone chrome, a face onto a standalone browser, a trigger onto the popover

The colour combo was a wide two-zone field with a second door onto the Colors
panel; the symbol picker was a small square button with one. Both now subclass
one `ComboFieldControl`, so they are the same width, height, radius and trigger
by construction: click the face for the standalone picker, click the chevron for
the quick list. The symbol face opens a new floating browser over the OS's own
category, ordering and keyword plists out of CoreGlyphs.bundle — searchable,
categorised, trademark-restricted glyphs withheld.

The palette grows twelve to sixteen per table, filling the hue ring's four
widest gaps with lime, jade, indigo and magenta at each table's own saturation
and brightness. That gives the Style… popover's background grid a third row and
the tint grid its third row of four, and both grids gain an Other… row onto the
system colour picker — which the card sidebar's combo has had all along and the
primary styling surface never did. An arbitrary hex already round-tripped; it is
asserted now, including that an unquoted one is a YAML comment and no value.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:44:30 -04:00
parent ca0328be2e
commit ece33bbf78
17 changed files with 2030 additions and 322 deletions
+318
View File
@@ -0,0 +1,318 @@
import AppKit
import SwiftUI
/// **The standalone symbol picker** the symbol combo's face-zone door, and the glyph half of the
/// rhyme the colour combo's face already had: click the swatch, the Colors panel opens; click the
/// glyph, this opens (2026-08-09).
///
/// ### Why a panel and not a popover
///
/// Because the thing it is rhyming with is a panel. `NSColorPanel.shared` is a floating window that
/// **stays up while you keep choosing**, streaming each pick to whatever surface opened it, and every
/// property that makes it feel like a tool rather than a menu follows from that: it can be moved out
/// of the way, it can stay open across several cards, and it does not steal the board's key window
/// permanently. A popover would have been the third popover in a stack (the board popover already
/// hosts a symbol combo whose trigger opens one) and would have closed the moment the user looked
/// away. So: an `NSPanel`, `.utilityWindow`, non-activating, hosting SwiftUI.
///
/// ### Shared, exactly like the colour panel
///
/// One panel, borrowed in turn. `present()` hands it a new owner's closure and the previously
/// presenting surface simply stops receiving picks `SystemColorPanel`'s last-writer-wins, restated
/// for a window this app owns rather than one AppKit owns. That is what lets the card window's
/// sidebar, the board popover and a future caller all use it without any of them coordinating.
///
/// ### What a pick does
///
/// Fires `onSelect` **immediately**, and the panel stays open the colour panel's continuous
/// behaviour, one dimension over. The receiving surface routes it through `StyleCommand.apply` exactly
/// as a well click does, so a pick here is one undoable step on the same stack, feeding the same
/// batch bracket. Unlike a colour drag there is nothing to debounce: a click is already discrete.
@MainActor
final class SymbolBrowserPanel: NSObject {
static let shared = SymbolBrowserPanel()
private var panel: NSPanel?
private let model = SymbolBrowserModel()
private override init() { super.init() }
/// Opens the browser (or re-aims an open one) at one symbol field.
///
/// - Parameters:
/// - current: the value as written, so the browser can show which glyph is live.
/// - fallback: the level's default what **Use Default** goes back to, and what the browser
/// marks as selected when `current` is absent or unresolvable (`ItemSymbol.name(_:fallback:)`'s
/// lenient rule, which this control obeys like every other renderer).
/// - onSelect: a name to set, or `nil` to clear the key. `SymbolPicker.onSelect`'s contract
/// verbatim, so a caller wires the same closure to both doors.
func present(current: String?, fallback: String, onSelect: @escaping (String?) -> Void) {
model.current = current
model.fallback = fallback
model.onSelect = onSelect
let panel = self.panel ?? makePanel()
self.panel = panel
panel.makeKeyAndOrderFront(nil)
}
/// **The closure is dropped when the window closes, and only then.**
///
/// Not when the surface that opened it goes away, which is the tempting rule and the broken one:
/// opening this browser dismisses whichever popover the picker was mounted in (the board popover,
/// the Style popover), so a claim tied to the opener's lifetime would be released a frame after
/// it was made and every pick would go nowhere. The panel therefore holds the last closure it was
/// given outliving its opener on purpose, exactly as `SharedColorPanelSession` does until
/// another surface claims it or the user closes the window.
///
/// Closing it matters because that closure retains its opener's `BoardStore`. One board's worth,
/// bounded, and released here.
private func panelWillClose() {
model.onSelect = nil
}
private func makePanel() -> NSPanel {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 420),
styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.title = "Symbols"
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.minSize = NSSize(width: 460, height: 320)
panel.contentView = NSHostingView(rootView: SymbolBrowserView(model: model))
panel.center()
// A utility panel that never becomes key could not host a search field; this one must.
panel.becomesKeyOnlyIfNeeded = false
NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: panel,
queue: .main
) { _ in
MainActor.assumeIsolated { SymbolBrowserPanel.shared.panelWillClose() }
}
return panel
}
}
// MARK: - The model
/// The browser's live state, shared between the panel (which owns it across presentations) and the
/// SwiftUI view (which observes it).
///
/// A reference type rather than the view's own `@State` because the panel outlives any one
/// presentation: re-aiming the browser at a different card must update the open window, not build a
/// second one. `category` and `query` deliberately **persist** across re-aims a user who was
/// browsing Nature for one card is very likely still browsing Nature for the next, and resetting
/// their place would be the browser forgetting what it was doing.
@MainActor
@Observable
final class SymbolBrowserModel {
var current: String?
var fallback: String = ItemSymbol.card
/// The selected category's key, or `nil` for **All Symbols**.
var category: String?
var query: String = ""
/// The presenting surface's write. `@ObservationIgnored` because nothing observes it, and
/// unannotated for `ComboFieldControl.onFaceClick`'s reason this class is already main-actor
/// isolated, so the closure is called there by construction.
@ObservationIgnored
var onSelect: ((String?) -> Void)?
/// The grid's contents: the selected category (or everything), narrowed by the query.
///
/// The query searches **within the selected category**, not across the whole catalog. Searching
/// globally from inside a category would make the sidebar selection silently irrelevant the
/// moment a character was typed; this way the two controls compose, and All Symbols is right
/// there for a global search.
var visibleSymbols: [String] {
let contents = SymbolCatalog.contents()
let base: [String]
if let category, let hit = contents.categories.first(where: { $0.key == category }) {
base = hit.symbols
} else {
base = contents.allSymbols
}
return SymbolCatalog.search(query, in: base, keywords: contents.keywords)
}
/// What the board would actually draw for the current value the leading-well rule, so the
/// browser marks the glyph that is on screen rather than the string on disk.
var resolvedName: String {
if let current, ItemSymbol.exists(current) { return current }
return fallback
}
func select(_ name: String?) {
current = name
onSelect?(name)
}
}
// MARK: - The view
/// The panel's content: a category sidebar, a search field, and a scrolling grid.
///
/// The shape is the SF Symbols app's, deliberately it is the arrangement every Mac user who has
/// ever looked for a glyph already knows, and inventing a different one would be novelty for its own
/// sake.
private struct SymbolBrowserView: View {
@Bindable var model: SymbolBrowserModel
/// Focus starts in the search field: someone who opened a symbol browser is looking for a
/// symbol, and the overwhelmingly common next act is to type its name.
@FocusState private var searchFocused: Bool
private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }
/// The grid's well side the symbol popover's own enlarged well (`SymbolPickerLayout`), reused
/// so a glyph is the same size in both surfaces and the eye does not have to re-scale between
/// them.
private var wellSide: CGFloat {
(StyleEditorLayout.wellSide(bodyPointSize: pointSize) * SymbolPickerLayout.gridScale).rounded()
}
private var spacing: CGFloat { StyleEditorLayout.wellSpacing(bodyPointSize: pointSize) }
var body: some View {
NavigationSplitView {
sidebar
.navigationSplitViewColumnWidth(min: 150, ideal: 170, max: 240)
} detail: {
detail
}
.frame(minWidth: 460, minHeight: 320)
}
// MARK: Sidebar
private var sidebar: some View {
List(selection: $model.category) {
Label("All Symbols", systemImage: "square.grid.2x2")
.tag(String?.none)
Section("Categories") {
ForEach(SymbolCatalog.categories) { category in
Label(category.title, systemImage: category.icon)
.tag(String?.some(category.key))
}
}
}
.listStyle(.sidebar)
}
// MARK: Detail
private var detail: some View {
let symbols = model.visibleSymbols
return VStack(alignment: .leading, spacing: spacing) {
searchField
if symbols.isEmpty {
ContentUnavailableView.search(text: model.query)
} else {
grid(symbols)
}
Divider()
footer
}
.padding(spacing)
}
private var searchField: some View {
HStack(spacing: spacing) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search Symbols", text: $model.query)
.textFieldStyle(.plain)
.focused($searchFocused)
// **Escape steps outward one layer per press** the app's standing Escape grammar
// (`BoardRenameField`, the symbol popover's own field): a non-empty query clears
// itself, an empty one lets the press through to the panel's own close.
.onKeyPress(.escape) {
guard !model.query.isEmpty else { return .ignored }
model.query = ""
return .handled
}
if !model.query.isEmpty {
Button {
model.query = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear Search")
}
}
.padding(.horizontal, spacing)
.padding(.vertical, spacing / 2)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
.onAppear { searchFocused = true }
}
private func grid(_ symbols: [String]) -> some View {
ScrollView(.vertical) {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: wellSide + spacing), spacing: spacing)],
spacing: spacing
) {
// `id: \.self` is safe and cheap here: the OS's catalog is deduplicated by
// construction (it is a dictionary's keys), so the names are unique.
ForEach(symbols, id: \.self) { name in
well(name)
}
}
.padding(.vertical, spacing / 2)
}
}
private func well(_ name: String) -> some View {
let isSelected = model.resolvedName == name
return Button {
model.select(name)
} label: {
Image(systemName: name)
.font(.system(size: (pointSize * SymbolPickerLayout.gridScale).rounded()))
.frame(width: wellSide, height: wellSide)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.25)) : AnyShapeStyle(.clear))
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(name)
.accessibilityLabel(name)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
}
/// The live value, stated, and the one control that is not a glyph: **Use Default**, which is the
/// popover's leading default well restated as a button the browser has thousands of wells and
/// no sensible place to hide a special one among them.
private var footer: some View {
HStack(spacing: spacing) {
Image(systemName: model.resolvedName)
.foregroundStyle(.secondary)
Text(model.current ?? "Default (\(model.fallback))")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: spacing)
Button("Use Default") {
model.select(nil)
}
.disabled(model.current == nil)
}
}
}