Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled 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 `PickerRectControl.onClick`'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)
|
|
}
|
|
}
|
|
}
|