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 used to be `ColorComboView.Coordinator`'s panel handling, lifted out of it when a second /// surface needed the identical takeover: `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. /// `ColorComboView` is retired now (2026-08-10, `ColorSwatchPicker.swift`'s own header), and every /// caller left — the Style… popover's Background section, the symbol popover's tint section, and the /// card sidebar's own colour rectangle — reaches this class through the one shared instance /// `SharedColorPanelSession` holds, below. /// /// ### 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 (`changeColor(_:)` below). That /// 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 — `SharedColorPanelSession.present`'s own ~400ms trailing debounce is /// that owed debounce for every caller today, 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 it does **not** /// work for a surface whose door onto the panel sits *inside a transient popover*: the Style… /// popover's Background section, the symbol popover's tint section, and, since 2026-08-10, the card /// sidebar's own colour rectangle's popover (`ColorSwatchPicker`'s **Other…** row) are all like this. /// 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. (`CardStyleSection` used to be the one exception — a card window's sidebar outlives /// any panel opened from it, so it held its own `SystemColorPanel` and its own debounce `Task` /// directly. It reaches this shared session too now that its own colour control opens the panel from /// *inside* a popover rather than from a permanently-mounted face.) /// /// 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 lives here because the state it needs 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 every caller keeps /// that one settled value **out of** `StyleRecents` — that row remembers deliberate palette picks, /// not colours a drag swept through, so every `write` closure above writes raw rather than through /// `StyleCommand.apply`. @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) } } } }