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) } } }