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
319 lines
13 KiB
Swift
319 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|