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
125 lines
6.1 KiB
Swift
125 lines
6.1 KiB
Swift
import AppKit
|
|
|
|
/// **The shared Colors panel, as one takeover every surface can borrow** — `NSColorPanel.shared`
|
|
/// seeded, targeted, and translated back into the app's stored-value vocabulary.
|
|
///
|
|
/// This is `ColorComboView.Coordinator`'s panel handling, lifted out of it. That coordinator was the
|
|
/// only thing in the app that opened the system picker, so the logic could live inside it; now three
|
|
/// surfaces do — the colour combo's face and its **Other…** row, the Style… popover's Background
|
|
/// section, and the symbol popover's tint section (2026-08-09) — and the parts that must not be
|
|
/// re-derived are the awkward ones: `NSColorPanel` is a **singleton with a settable target and no
|
|
/// getter for it**, so "is the panel still mine to detach from" has nowhere to live but in a static,
|
|
/// and a second surface taking the panel over has to leave the first one's teardown harmless.
|
|
///
|
|
/// ### What it hands back
|
|
///
|
|
/// Not an `NSColor` — the **stored string**: the palette name when the picked colour lands exactly
|
|
/// on one of the caller's own palette entries, `#RRGGBB[AA]` otherwise. That rule (name wins over
|
|
/// hex) is the same one `ColorComboModel.match` applies to a value already on disk, which is what
|
|
/// makes a colour survive a round trip through the panel without drifting from `light-cayenne` into
|
|
/// an anonymous `#B6071E`.
|
|
///
|
|
/// ### It fires continuously
|
|
///
|
|
/// `NSColorPanel`'s action runs on every tick of a drag, not on release, and that is deliberate: a
|
|
/// live preview is the point of opening the panel at all. **Every caller therefore owes a debounce**
|
|
/// before the value reaches disk — `CardStyleSection.debounceBackground` is the pattern, ~400ms
|
|
/// trailing, and a drag that passes through a palette-exact colour mid-gesture must not be recorded
|
|
/// in `StyleRecents` the way a deliberate pick is.
|
|
@MainActor
|
|
final class SystemColorPanel: NSObject {
|
|
|
|
/// Whichever instance most recently took the shared panel over. `weak`, so an owner that never
|
|
/// got around to detaching — a window closed from under it — does not keep the next one from
|
|
/// being collected either.
|
|
private static weak var currentOwner: SystemColorPanel?
|
|
|
|
private var onChange: ((String) -> Void)?
|
|
/// The palette a picked colour is matched against before it is written — the caller's own role
|
|
/// table, so a background pick checks the backgrounds and a tint pick checks the tints.
|
|
private var palette: [PaletteColor] = []
|
|
|
|
/// Seeds the panel with `seed` (black when the field carries no resolvable colour), takes it
|
|
/// over, and asks for continuous updates.
|
|
///
|
|
/// Opening it again from the same owner re-seeds rather than stacking — the panel is one window
|
|
/// and this is how a second click on the same face behaves.
|
|
func present(
|
|
seed: NSColor?,
|
|
matching palette: [PaletteColor],
|
|
showsAlpha: Bool = true,
|
|
onChange: @escaping (String) -> Void
|
|
) {
|
|
self.onChange = onChange
|
|
self.palette = palette
|
|
let panel = NSColorPanel.shared
|
|
panel.showsAlpha = showsAlpha
|
|
panel.color = seed ?? .black
|
|
panel.setTarget(self)
|
|
panel.setAction(#selector(changeColor(_:)))
|
|
Self.currentOwner = self
|
|
panel.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
/// Releases the panel **only if this instance still holds it** — last-writer-wins, so a view
|
|
/// torn down after another surface took the panel over does not silently unhook that surface.
|
|
func detach() {
|
|
guard Self.currentOwner === self else { return }
|
|
let panel = NSColorPanel.shared
|
|
panel.setTarget(nil)
|
|
panel.setAction(nil)
|
|
Self.currentOwner = nil
|
|
onChange = nil
|
|
}
|
|
|
|
/// The panel's own action. A colour no sRGB conversion can express is dropped rather than
|
|
/// guessed at — `paletteHexString`'s `nil`, which no picker swatch actually produces.
|
|
@objc private func changeColor(_ sender: NSColorPanel) {
|
|
guard let hex = sender.color.paletteHexString else { return }
|
|
onChange?(Palette.name(forHex: hex, in: palette) ?? hex)
|
|
}
|
|
}
|
|
|
|
// MARK: - The session a transient surface borrows
|
|
|
|
/// **The Colors panel for surfaces that do not outlive it, debounce included.**
|
|
///
|
|
/// The obvious shape is a `SystemColorPanel` in the opening view's own state, and that is exactly
|
|
/// what `CardStyleSection` does — correctly, because a card window's sidebar outlives any panel
|
|
/// opened from it. It does **not** work for the two surfaces that gained an **Other…** on
|
|
/// 2026-08-09: the Style… popover's Background section and the symbol popover's tint section are
|
|
/// both inside *transient popovers*. Opening the Colors panel takes key status away, the popover
|
|
/// dismisses, the view's state goes with it — and the panel the user is now dragging in has nothing
|
|
/// left to talk to. The gesture would break itself.
|
|
///
|
|
/// So the session lives above every view: one panel, one pending write, replaced by whichever
|
|
/// surface opened it last. Callers capture their store **weakly**, so a board closed while its
|
|
/// Colors panel is still up drops the write instead of resurrecting a dead store.
|
|
///
|
|
/// The debounce is `CardStyleSection.debounceBackground`'s, restated because the state it needs now
|
|
/// has to live somewhere a dismissed popover cannot take with it: the panel's action is continuous,
|
|
/// and only the value the user is still on ~400ms after the last tick reaches disk. A drag is
|
|
/// therefore one write, and never feeds `StyleRecents` — that row remembers deliberate palette
|
|
/// picks, not colours a drag swept through.
|
|
@MainActor
|
|
enum SharedColorPanelSession {
|
|
|
|
private static let panel = SystemColorPanel()
|
|
private static var commit: Task<Void, Never>?
|
|
|
|
static func present(
|
|
seed: NSColor?,
|
|
matching palette: [PaletteColor],
|
|
write: @escaping (String) -> Void
|
|
) {
|
|
panel.present(seed: seed, matching: palette) { value in
|
|
commit?.cancel()
|
|
commit = Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
guard !Task.isCancelled else { return }
|
|
write(value)
|
|
}
|
|
}
|
|
}
|
|
}
|