Files
lanework/Kanban/UI/SystemColorPanel.swift
T
rzen 4210bbe0a7 The combo's trigger retires — each picker is one rectangle again, taller, and Style becomes Appearance in the sidebar
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
2026-08-09 20:44:47 -04:00

129 lines
6.5 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 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<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)
}
}
}
}