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