Skip to main content

teksilo_widgets/
color_picker.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ColorPicker` — embeddable composite color selector.
5//!
6//! Combines a 2D HSV canvas, 1D hue and alpha strips, RGB and HSV
7//! component spinners, a hex input, a current-color preview, and an
8//! optional preset swatch grid into a single bound widget. Driven by a
9//! `Signal<Color>` (or `Signal<Option<Color>>`) source of truth — every
10//! subcomponent reads from / writes to the same signal so the various
11//! representations stay in lockstep.
12//!
13//! # Layouts
14//!
15//! - [`ColorPickerLayout::Compact`] — HSV canvas + hue strip + hex
16//!   input. Minimal vertical footprint, suitable for popovers.
17//! - [`ColorPickerLayout::Standard`] (default) — HSV canvas + hue
18//!   strip + alpha strip (when enabled), with RGB spinners, hex
19//!   input, and preset swatches stacked beneath. The everything-on
20//!   layout for inspector panes and settings dialogs.
21//! - [`ColorPickerLayout::Wide`] — HSV canvas with strips on the
22//!   right, spinners stacked vertically alongside the swatch grid.
23//!   For wide property pages.
24//!
25//! # Accessibility
26//!
27//! Root: `Role::Group` with a localized
28//! label and `Live::Polite` so screen readers announce committed color
29//! changes. The HSV canvas's subtree is excluded from the AT tree
30//! (no ARIA precedent for 2D pointer gestures); the hue strip, alpha
31//! strip, RGB / HSV spinners, hex input, current-color preview, and
32//! swatch grid each carry their own appropriate role and value.
33//!
34//! ## Touch and pen
35//!
36//! The hue strip, the alpha strip and the HSV canvas are **continuous
37//! manipulators** — the value each produces *is* the press position — so all
38//! three declare `touch_action(NONE)`: a finger that lands on one adjusts it
39//! rather than scrolling the surface the picker sits in.  What `NONE` forbids,
40//! with a test on it, is a two-contact pinch begun on one of them reaching the
41//! surface underneath; the press capture each takes on its own `PointerDown` is
42//! what separately keeps an enclosing scroller from taking the gesture away.
43//! `docs/touch-and-pen.md` §7.3.
44//!
45//! The strips are 14 dp across and cannot grow — the panel's column geometry is
46//! built around them — so they make the 24 dp shortfall up between the pointer
47//! and the arena, through `Widget::hit_outset`, on the short axis only. A
48//! 22 dp preset swatch earns the same widening, on both of its axes. The
49//! strips also report what they painted through `Widget::target_regions`, so
50//! the knob a user aims at is visible to a conformance audit even though no
51//! layout ever produced it.
52
53pub mod alpha_strip;
54pub mod hsv_canvas;
55pub mod hue_strip;
56pub mod state;
57pub mod swatch;
58pub mod swatch_grid;
59
60#[cfg(test)]
61mod tests;
62
63use std::cell::RefCell;
64use std::rc::Rc;
65use teksilo_i18n::lit;
66use teksilo_i18n::localized;
67
68use teksilo_canvas::{Rect, SizeProposal};
69use teksilo_core::accessibility::AccessNodeBuilder;
70use teksilo_core::accesskit::{Action, Live, Role};
71use teksilo_core::build_context::BuildContext;
72use teksilo_core::signal::{Prop, Signal};
73use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
74use teksilo_core::widget_id::WidgetId;
75use teksilo_i18n::{LocalizedString, resolve_message_widget};
76use teksilo_tokens::{Color, Orientation};
77
78use self::alpha_strip::AlphaStrip;
79use self::hsv_canvas::HsvCanvas;
80use self::hue_strip::HueStrip;
81use self::state::ColorComponents;
82use self::swatch_grid::SwatchGrid;
83use crate::button::{Button, ButtonVariant};
84use crate::hex_color_input::HexColorInput;
85use crate::primitives::{HStack, Spacer, TextWidget};
86use crate::spin_box::SpinBox;
87
88pub use self::swatch::ColorSwatch;
89
90/// Default 12-color preset palette (Int UI–flavored). Apps can use
91/// this verbatim or pass their own via [`ColorPicker::swatches`].
92pub const DEFAULT_SWATCHES: [Color; 12] = [
93    Color::new(0.91, 0.30, 0.24, 1.0), // red
94    Color::new(0.95, 0.60, 0.20, 1.0), // orange
95    Color::new(0.96, 0.83, 0.27, 1.0), // yellow
96    Color::new(0.42, 0.70, 0.35, 1.0), // green
97    Color::new(0.20, 0.66, 0.61, 1.0), // teal
98    Color::new(0.21, 0.52, 0.89, 1.0), // blue
99    Color::new(0.36, 0.36, 0.83, 1.0), // indigo
100    Color::new(0.66, 0.40, 0.85, 1.0), // purple
101    Color::new(0.92, 0.45, 0.68, 1.0), // pink
102    Color::new(0.55, 0.36, 0.20, 1.0), // brown
103    Color::new(0.06, 0.06, 0.06, 1.0), // near-black
104    Color::new(0.96, 0.96, 0.96, 1.0), // near-white
105];
106
107pub use teksilo_core::styles::ColorPickerLayout;
108
109/// Internal binding to either a non-nullable `Signal<Color>` or a
110/// nullable `Signal<Option<Color>>`. The picker always operates on a
111/// concrete `Color` internally — the nullable case treats `None` as
112/// "transparent black" for picker math, then writes back `Some(color)`
113/// on every commit.
114#[derive(Clone)]
115enum ColorBinding {
116    Required(Signal<Color>),
117    Nullable {
118        source: Signal<Option<Color>>,
119        proxy: Signal<Color>,
120    },
121}
122
123impl ColorBinding {
124    fn value(&self) -> Signal<Color> {
125        match self {
126            Self::Required(s) => s.clone(),
127            Self::Nullable { proxy, .. } => proxy.clone(),
128        }
129    }
130}
131
132/// Embeddable HSV+RGB+hex+alpha+swatches color picker.
133///
134/// See the [module docs](self) for layout options, accessibility, and
135/// integration patterns. Use [`ColorEdit`](crate::color_edit::ColorEdit)
136/// to wrap this in a compact trigger + popover pattern.
137///
138/// ```ignore
139/// use teksilo_core::signal::Signal;
140/// use teksilo_tokens::Color;
141/// use teksilo_widgets::color_picker::{ColorPicker, ColorPickerLayout};
142///
143/// let color = ctx.signal(Color::new(0.42, 0.70, 0.35, 1.0));
144/// let _picker = ColorPicker::new(color)
145///     .layout(ColorPickerLayout::Compact)
146///     .alpha_enabled(false);
147/// ```
148pub struct ColorPicker {
149    binding: ColorBinding,
150    alpha_enabled: bool,
151    show_hsv_canvas: bool,
152    show_hue_strip: bool,
153    show_alpha_strip: Option<bool>,
154    show_rgb_spinners: bool,
155    show_hsv_spinners: bool,
156    show_hex_input: bool,
157    show_preview: bool,
158    show_swatches: bool,
159    show_footer: bool,
160    on_done: Option<Rc<dyn Fn(&mut EventContext)>>,
161    on_cancel: Option<Rc<dyn Fn(&mut EventContext)>>,
162    swatches: Prop<Vec<Color>>,
163    swatch_columns: usize,
164    layout: ColorPickerLayout,
165    label: Option<LocalizedString>,
166    /// Enabled state, static or reactive; forwarded to the arena at
167    /// build time.
168    enabled: Prop<bool>,
169    /// Cache of the most recent color formatted as a hex string. The
170    /// live-region effect updates this whenever the bound color changes;
171    /// `accessibility()` reads it (via `binding.value().get()` then
172    /// `to_hex_upper`) — keeping the cell here means the effect's
173    /// dirty-marking is what triggers re-resolution, instead of every
174    /// AT walk allocating a fresh string.
175    last_announced_hex: Rc<RefCell<Option<String>>>,
176    /// Per-call style override. Higher precedence than the theme-wide
177    /// `style_slots.color_picker` slot, which in turn beats the default
178    /// `RecipeColorPickerStyle`.
179    style_override: Option<teksilo_core::styles::SharedColorPickerStyle>,
180    root_child_id: Option<WidgetId>,
181    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
182    /// with the rich / composite slots — every setter clears the other two so
183    /// the last call wins.
184    tooltip_text: Option<LocalizedString>,
185    /// Optional rich tooltip source (registry key or inline content).
186    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
187    /// Optional composite tooltip body (arbitrary widget tree).
188    composite_tooltip_content: Option<Box<dyn Widget>>,
189}
190
191impl ColorPicker {
192    /// Bind to a non-nullable color signal.
193    pub fn new(value: Signal<Color>) -> Self {
194        Self::from_binding(ColorBinding::Required(value))
195    }
196
197    /// Bind to a nullable color signal. `None` is treated as
198    /// transparent black for picker math; any commit produces a
199    /// concrete `Some(color)`. Apps that want a "clear to None"
200    /// affordance should expose a separate Clear button alongside
201    /// the picker.
202    pub fn nullable(value: Signal<Option<Color>>) -> Self {
203        let proxy = Signal::new(value.get().unwrap_or(Color::TRANSPARENT));
204        Self::from_binding(ColorBinding::Nullable {
205            source: value,
206            proxy,
207        })
208    }
209
210    fn from_binding(binding: ColorBinding) -> Self {
211        Self {
212            binding,
213            alpha_enabled: false,
214            show_hsv_canvas: true,
215            show_hue_strip: true,
216            show_alpha_strip: None, // defaults to alpha_enabled
217            show_rgb_spinners: true,
218            // On by default: the numeric entry is the canvas's single-pointer
219            // alternative (WCAG 2.2 SC 2.5.7), so an application does not have
220            // to ask for it. `show_hsv_spinners(false)` still turns it off, for
221            // a picker whose canvas is hidden too.
222            show_hsv_spinners: true,
223            show_hex_input: true,
224            show_preview: true,
225            show_swatches: true,
226            show_footer: false,
227            on_done: None,
228            on_cancel: None,
229            swatches: Prop::Static(DEFAULT_SWATCHES.to_vec()),
230            swatch_columns: 6,
231            layout: ColorPickerLayout::Standard,
232            label: None,
233            enabled: Prop::Static(true),
234            last_announced_hex: Rc::new(RefCell::new(None)),
235            style_override: None,
236            root_child_id: None,
237            tooltip_text: None,
238            rich_tooltip_source: None,
239            composite_tooltip_content: None,
240        }
241    }
242
243    /// Per-call style override. Higher precedence than the theme-wide
244    /// `style_slots.color_picker` slot.
245    pub fn style(mut self, style: impl teksilo_core::styles::ColorPickerStyle) -> Self {
246        self.style_override = Some(Rc::new(style));
247        self
248    }
249
250    /// Enable or disable the alpha channel (hue-strip alpha strip + `a` spinner + hex digit pair).
251    pub fn alpha_enabled(mut self, e: bool) -> Self {
252        self.alpha_enabled = e;
253        self
254    }
255
256    /// Show or hide the 2D HSV gradient canvas. Hidden in headless or
257    /// accessibility-only contexts where the pointer-drag surface is
258    /// not useful.
259    pub fn show_hsv_canvas(mut self, s: bool) -> Self {
260        self.show_hsv_canvas = s;
261        self
262    }
263
264    /// Show or hide the vertical hue selection strip.
265    pub fn show_hue_strip(mut self, s: bool) -> Self {
266        self.show_hue_strip = s;
267        self
268    }
269
270    /// Show or hide the vertical alpha strip. Defaults to the value of
271    /// `alpha_enabled`; call this to decouple them (e.g. show the strip
272    /// without enabling the alpha spinner).
273    pub fn show_alpha_strip(mut self, s: bool) -> Self {
274        self.show_alpha_strip = Some(s);
275        self
276    }
277
278    /// Show or hide the RGB (0–255) component spinners row.
279    pub fn show_rgb_spinners(mut self, s: bool) -> Self {
280        self.show_rgb_spinners = s;
281        self
282    }
283
284    /// Show or hide the HSV (hue 0–359°, saturation 0–100%, value 0–100%) spinners row.
285    pub fn show_hsv_spinners(mut self, s: bool) -> Self {
286        self.show_hsv_spinners = s;
287        self
288    }
289
290    /// Show or hide the hex string input field.
291    pub fn show_hex_input(mut self, s: bool) -> Self {
292        self.show_hex_input = s;
293        self
294    }
295
296    /// Show or hide the current-color preview swatch (Standard / Wide layouts).
297    pub fn show_preview(mut self, s: bool) -> Self {
298        self.show_preview = s;
299        self
300    }
301
302    /// Show or hide the preset swatch grid (Standard / Wide layouts only).
303    pub fn show_swatches(mut self, s: bool) -> Self {
304        self.show_swatches = s;
305        self
306    }
307
308    /// Show a Done / Cancel footer at the bottom of the picker.
309    /// Default `false` for embedded use (the bound signal is the
310    /// commit channel — there is no "uncommitted" state). Wrappers
311    /// that present the picker as a popover (e.g. `ColorEdit`)
312    /// flip this to `true` so the user has explicit accept / dismiss
313    /// affordances; the buttons fire [`Self::on_done`] /
314    /// [`Self::on_cancel`] respectively.
315    pub fn show_footer(mut self, s: bool) -> Self {
316        self.show_footer = s;
317        self
318    }
319
320    /// Callback fired when the user activates the footer's Done
321    /// button. The picker has already been writing through to the
322    /// bound signal as the user dragged / typed, so Done's job is
323    /// purely to dismiss the surrounding surface (popover, sheet,
324    /// dialog). Only meaningful when `show_footer(true)`.
325    pub fn on_done(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
326        self.on_done = Some(Rc::new(f));
327        self
328    }
329
330    /// Callback fired when the user activates the footer's Cancel
331    /// button. The picker itself does **not** restore any value —
332    /// that's the caller's responsibility (e.g. ColorEdit captures a
333    /// snapshot at popover-open time and writes it back here). The
334    /// callback's typical implementation is
335    /// `value.set(snapshot.get()); ctx.dismiss_self_overlay_chain();`.
336    /// Only meaningful when `show_footer(true)`.
337    pub fn on_cancel(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
338        self.on_cancel = Some(Rc::new(f));
339        self
340    }
341
342    /// Replace the default 12-color [`DEFAULT_SWATCHES`] with a custom
343    /// palette — statically, or reactively via a bound `Signal<Vec<Color>>`
344    /// that updates live without rebuilding the picker.
345    pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self {
346        self.swatches = s.into();
347        self
348    }
349
350    /// Number of columns in the preset swatch grid. Defaults to 6;
351    /// clamped to at least 1.
352    pub fn swatch_columns(mut self, n: usize) -> Self {
353        self.swatch_columns = n.max(1);
354        self
355    }
356
357    /// Select the overall layout variant. Defaults to [`ColorPickerLayout::Standard`].
358    pub fn layout(mut self, l: ColorPickerLayout) -> Self {
359        self.layout = l;
360        self
361    }
362
363    /// Set the accessible group label for the picker root node.
364    /// Defaults to the localized "Color picker" string.
365    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
366        self.label = Some(label.into());
367        self
368    }
369
370    /// Set the enabled state, statically or reactively. Forwarded to the
371    /// arena at build time.
372    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
373        self.enabled = enabled.into();
374        self
375    }
376
377    /// Attach a plain single-line tooltip shown after a hover delay.
378    ///
379    /// Mutually exclusive with [`Self::rich_tooltip`], [`Self::rich_tooltip_content`],
380    /// and [`Self::composite_tooltip`] — the last setter called wins.
381    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
382        self.tooltip_text = Some(text.into());
383        self.rich_tooltip_source = None;
384        self.composite_tooltip_content = None;
385        self
386    }
387
388    /// Attach a rich tooltip looked up from the registry by key.
389    ///
390    /// Mutually exclusive with the other tooltip setters — the last call wins.
391    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
392        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
393        self.tooltip_text = None;
394        self.composite_tooltip_content = None;
395        self
396    }
397
398    /// Attach an inline rich tooltip from an already-constructed [`crate::tooltip::TooltipContent`].
399    ///
400    /// Mutually exclusive with the other tooltip setters — the last call wins.
401    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
402        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
403        self.tooltip_text = None;
404        self.composite_tooltip_content = None;
405        self
406    }
407
408    /// Attach a composite tooltip whose body is an arbitrary widget tree.
409    ///
410    /// Mutually exclusive with the other tooltip setters — the last call wins.
411    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
412        self.composite_tooltip_content = Some(Box::new(content));
413        self.tooltip_text = None;
414        self.rich_tooltip_source = None;
415        self
416    }
417
418    /// Read the current bound color. Convenience for tests / apps that
419    /// hold a `ColorPicker` reference; otherwise prefer reading the
420    /// `Signal<Color>` you passed in.
421    pub fn current(&self) -> Color {
422        self.binding.value().get()
423    }
424}
425
426impl std::fmt::Debug for ColorPicker {
427    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
428        f.debug_struct("ColorPicker")
429            .field("alpha_enabled", &self.alpha_enabled)
430            .field("layout", &self.layout)
431            .field("enabled", &self.enabled.get())
432            .finish_non_exhaustive()
433    }
434}
435
436impl Widget for ColorPicker {
437    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
438        // ── Bridge nullable binding ↔ proxy ──
439        if let ColorBinding::Nullable { source, proxy } = &self.binding {
440            // source → proxy (external writes update internal proxy)
441            {
442                let proxy = proxy.clone();
443                ctx.effect(source, move |new| {
444                    let resolved = new.unwrap_or(Color::TRANSPARENT);
445                    if proxy.get() != resolved {
446                        proxy.set(resolved);
447                    }
448                });
449            }
450            // proxy → source (internal commits update external source)
451            {
452                let source = source.clone();
453                ctx.effect(proxy, move |c| {
454                    if source.get() != Some(*c) {
455                        source.set(Some(*c));
456                    }
457                });
458            }
459        }
460
461        let value = self.binding.value();
462        let components = Rc::new(ColorComponents::new(ctx, value.clone()));
463
464        // ── Live-region announcement on commit ──
465        // Only fires when dragging is false (avoids per-frame chatter
466        // mid-drag). The last-announced cache prevents repeating
467        // identical announcements when other channels change.
468        // Live-region hex cache — refreshed when the bound color settles
469        // (i.e. not mid-drag). `accessibility()` reads the cached string
470        // when present and falls back to a fresh format otherwise.
471        {
472            let dragging = components.dragging.clone();
473            let last_announced = self.last_announced_hex.clone();
474            let alpha = self.alpha_enabled;
475            ctx.effect(&value, move |c| {
476                if dragging.get() {
477                    return;
478                }
479                let hex = c.to_hex_upper(alpha);
480                let needs_update = last_announced.borrow().as_deref() != Some(hex.as_str());
481                if needs_update {
482                    *last_announced.borrow_mut() = Some(hex);
483                }
484            });
485        }
486
487        let self_id = ctx.self_id();
488        // Forward the enabled state into the arena; see IconButton. Inner
489        // sub-widgets (HsvCanvas, HueStrip, AlphaStrip, SwatchGrid)
490        // inherit disabled via the ancestor walk — their per-widget
491        // `enabled` snapshot below is only consulted at build time and
492        // then they too forward into the arena, so the AND semantics
493        // fall out for free.
494        ctx.enabled_when(self_id, self.enabled.clone());
495
496        // ── Resolve flags ──
497        let alpha_enabled = self.alpha_enabled;
498        let show_alpha_strip = self.show_alpha_strip.unwrap_or(alpha_enabled);
499        let layout = self.layout;
500        let enabled = self.enabled.get();
501        use crate::styles::recipe_color_picker_style as cp;
502
503        // ── Build subcomponents ──
504
505        // Top row: HSV canvas + hue strip + alpha strip
506        let mut top_row = HStack::new().spacing(cp::GAP);
507        if self.show_hsv_canvas {
508            let canvas = HsvCanvas::new(
509                components.hue.clone(),
510                components.saturation.clone(),
511                components.value_hsv.clone(),
512                components.set_hsv.clone(),
513                components.dragging.clone(),
514            )
515            .enabled(enabled);
516            // The canvas's own children are decoration (three stacked gradient
517            // layers), so its subtree stays out of the AT tree — but the canvas
518            // node itself does not: it carries the four
519            // saturation-and-brightness steps as custom actions, which is the
520            // route an assistive client has to a 2-D value.
521            use teksilo_core::widget_builder::WidgetBuilder;
522            top_row = top_row.child(
523                canvas.access_subtree(teksilo_core::widget_builder::AccessSubtreeMode::Exclude),
524            );
525        }
526        if self.show_hue_strip {
527            let hue = HueStrip::new(
528                components.hue.clone(),
529                components.set_hue.clone(),
530                components.dragging.clone(),
531            )
532            .orientation(Orientation::Vertical)
533            .enabled(enabled)
534            .label(resolve_message_widget("color-picker-hue-label", &[]));
535            top_row = top_row.child(hue);
536        }
537        if alpha_enabled && show_alpha_strip {
538            let alpha = AlphaStrip::new(
539                value.clone(),
540                components.alpha.clone(),
541                components.set_alpha.clone(),
542                components.dragging.clone(),
543            )
544            .orientation(Orientation::Vertical)
545            .enabled(enabled)
546            .label(resolve_message_widget("color-picker-alpha-label", &[]));
547            top_row = top_row.child(alpha);
548        }
549        let top_row_id = ctx.add(top_row);
550
551        // Preview + hex row — Standard / Wide only. Empty row in
552        // Compact (Compact uses the compact-hex slot instead).
553        let preview_row_id: Option<WidgetId> =
554            if layout != ColorPickerLayout::Compact && (self.show_preview || self.show_hex_input) {
555                let mut row = HStack::new().spacing(cp::GAP);
556                if self.show_preview {
557                    row = row.child(
558                        ColorSwatch::new(value.clone())
559                            .size(cp::PREVIEW_HEIGHT)
560                            .corner_radius(cp::PREVIEW_CORNER_RADIUS)
561                            .label(localized(move || {
562                                resolve_message_widget("color-picker-current-color-label", &[])
563                            })),
564                    );
565                }
566                if self.show_hex_input {
567                    let hex = HexColorInput::new(value.clone())
568                        .alpha_enabled(alpha_enabled)
569                        .label(localized(move || {
570                            resolve_message_widget("color-picker-hex-label", &[])
571                        }))
572                        .width(cp::HEX_FIELD_WIDTH);
573                    row = row.child(hex);
574                }
575                Some(ctx.add(row))
576            } else {
577                None
578            };
579
580        // RGB spinners row — Standard / Wide only (the Compact layout
581        // doesn't include them, so creating one in Compact would leak
582        // an orphan root in the arena and absorb hit-tests at the
583        // pre-layout fallback bounds). Built eagerly so closures don't
584        // fight over &mut ctx. Bridges observe the mutable `value`
585        // signal (not the derived `components.red` etc., which are
586        // ReadOnly and don't support `ctx.effect`).
587        let rgb_row_id: Option<WidgetId> =
588            if layout != ColorPickerLayout::Compact && self.show_rgb_spinners {
589                let r_spin = make_byte_spinner_from_value(
590                    ctx,
591                    value.clone(),
592                    |c| c.r(),
593                    components.set_red.clone(),
594                    enabled,
595                    cp::SPINNER_FIELD_WIDTH,
596                );
597                let g_spin = make_byte_spinner_from_value(
598                    ctx,
599                    value.clone(),
600                    |c| c.g(),
601                    components.set_green.clone(),
602                    enabled,
603                    cp::SPINNER_FIELD_WIDTH,
604                );
605                let b_spin = make_byte_spinner_from_value(
606                    ctx,
607                    value.clone(),
608                    |c| c.b(),
609                    components.set_blue.clone(),
610                    enabled,
611                    cp::SPINNER_FIELD_WIDTH,
612                );
613                let mut row = HStack::new()
614                    .spacing(cp::GAP)
615                    .child(spinner_cell("color-picker-red-short", r_spin))
616                    .child(spinner_cell("color-picker-green-short", g_spin))
617                    .child(spinner_cell("color-picker-blue-short", b_spin));
618                if alpha_enabled {
619                    let a_spin = make_byte_spinner_from_value(
620                        ctx,
621                        value.clone(),
622                        |c| c.a(),
623                        components.set_alpha.clone(),
624                        enabled,
625                        cp::SPINNER_FIELD_WIDTH,
626                    );
627                    row = row.child(spinner_cell("color-picker-alpha-short", a_spin));
628                }
629                Some(ctx.add(row))
630            } else {
631                None
632            };
633
634        // HSV spinners row — same pattern. Standard / Wide only.
635        let hsv_row_id: Option<WidgetId> =
636            if layout != ColorPickerLayout::Compact && self.show_hsv_spinners {
637                let h_spin = make_hue_spinner_from_value(
638                    ctx,
639                    value.clone(),
640                    components.set_hue.clone(),
641                    enabled,
642                    cp::SPINNER_FIELD_WIDTH,
643                );
644                let s_spin = make_percent_spinner_from_value(
645                    ctx,
646                    value.clone(),
647                    |c| c.to_hsv().1,
648                    components.set_saturation.clone(),
649                    enabled,
650                    cp::SPINNER_FIELD_WIDTH,
651                );
652                let v_spin = make_percent_spinner_from_value(
653                    ctx,
654                    value.clone(),
655                    |c| c.to_hsv().2,
656                    components.set_value_hsv.clone(),
657                    enabled,
658                    cp::SPINNER_FIELD_WIDTH,
659                );
660                Some(
661                    ctx.add(
662                        HStack::new()
663                            .spacing(cp::GAP)
664                            .child(spinner_cell("color-picker-hue-short", h_spin))
665                            .child(spinner_cell("color-picker-saturation-short", s_spin))
666                            .child(spinner_cell("color-picker-value-short", v_spin)),
667                    ),
668                )
669            } else {
670                None
671            };
672
673        // Compact-layout hex row.
674        let compact_hex_id: Option<WidgetId> =
675            if layout == ColorPickerLayout::Compact && self.show_hex_input {
676                Some(
677                    ctx.add(
678                        HexColorInput::new(value.clone())
679                            .alpha_enabled(alpha_enabled)
680                            .label(localized(move || {
681                                resolve_message_widget("color-picker-hex-label", &[])
682                            }))
683                            .width(cp::HEX_FIELD_WIDTH),
684                    ),
685                )
686            } else {
687                None
688            };
689
690        // Swatch grid — Standard / Wide only (Compact doesn't surface
691        // a swatch grid; building one anyway would orphan it in the
692        // arena and absorb hit-tests inside the trigger). A bound signal
693        // is an explicit opt-in — the grid shows even if currently empty
694        // (a live-updating list may start empty and populate later); a
695        // static palette additionally requires `show_swatches` and a
696        // non-empty vec.
697        let swatches_is_bound = matches!(self.swatches, Prop::Bound(_));
698        let swatches_id: Option<WidgetId> = if layout != ColorPickerLayout::Compact
699            && (swatches_is_bound || (self.show_swatches && !self.swatches.get().is_empty()))
700        {
701            let swatches_signal = self.swatches.as_signal();
702            let on_select: Rc<dyn Fn(Color, &mut EventContext)> = {
703                let value = value.clone();
704                Rc::new(move |c, _ctx_evt| {
705                    value.set(c);
706                })
707            };
708            Some(ctx.add(SwatchGrid::new(
709                swatches_signal,
710                value.clone(),
711                self.swatch_columns,
712                on_select,
713            )))
714        } else {
715            None
716        };
717
718        // Footer row (Cancel + Spacer + Done) — only when show_footer
719        // is set. Built once per layout. The buttons fire user-supplied
720        // callbacks; the picker doesn't dismiss anything itself (it
721        // doesn't own the surrounding surface).
722        let footer_id: Option<WidgetId> = if self.show_footer {
723            let mut row = HStack::new().spacing(cp::GAP).child(Spacer::new());
724            if let Some(cb) = self.on_cancel.clone() {
725                let cancel_btn = Button::new(localized(move || {
726                    resolve_message_widget("color-picker-cancel-label", &[])
727                }))
728                .variant(ButtonVariant::Plain)
729                .enabled(enabled)
730                .on_activate_fn(move |ctx_evt| cb(ctx_evt));
731                row = row.child(cancel_btn);
732            }
733            if let Some(cb) = self.on_done.clone() {
734                let done_btn = Button::new(localized(move || {
735                    resolve_message_widget("color-picker-done-label", &[])
736                }))
737                .variant(ButtonVariant::Filled)
738                .enabled(enabled)
739                .on_activate_fn(move |ctx_evt| cb(ctx_evt));
740                row = row.child(done_btn);
741            }
742            Some(ctx.add(row))
743        } else {
744            None
745        };
746
747        // ── Delegate body assembly + surface wrap to the active style.
748        let style = resolve_color_picker_style(&self.style_override, ctx);
749        let cfg = teksilo_core::styles::ColorPickerStyleConfig {
750            layout,
751            top_row: top_row_id,
752            preview_row: preview_row_id,
753            rgb_row: rgb_row_id,
754            hsv_row: hsv_row_id,
755            swatches: swatches_id,
756            footer: footer_id,
757            compact_hex: compact_hex_id,
758        };
759        let root_id = style.make_body(&cfg, ctx);
760        self.root_child_id = Some(root_id);
761
762        // ── Tooltip attachment ──
763        if let Some(content) = self.composite_tooltip_content.take() {
764            let delay = ctx.theme().motion.tooltip_delay_heavy;
765            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
766        } else if let Some(source) = self.rich_tooltip_source.clone() {
767            let delay = ctx.theme().motion.tooltip_delay;
768            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
769        } else if let Some(text) = self.tooltip_text.clone() {
770            let delay = ctx.theme().motion.tooltip_delay;
771            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
772        }
773
774        // Bind the value signal so the wrapper's accessibility() re-runs
775        // whenever the color changes (Live::Polite + set_value churns).
776        let self_id = ctx.self_id();
777        let registry = ctx.binding_registry();
778        value.bind_to(
779            self_id,
780            registry,
781            teksilo_core::binding::BindingLevel::AccessibilityOnly,
782        );
783
784        vec![root_id]
785    }
786
787    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
788        match self.root_child_id {
789            Some(id) => ctx
790                .child_layout_response(id, proposal)
791                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
792            None => proposal.resolve(0.0, 0.0).into(),
793        }
794    }
795
796    fn place_children(
797        &self,
798        bounds: Rect,
799        _proposal: SizeProposal,
800        children: &mut [WidgetPlacement],
801        _ctx: &LayoutContext,
802    ) {
803        for child in children.iter_mut() {
804            child.origin = bounds.origin();
805            child.size = bounds.size();
806        }
807    }
808
809    fn children(&self) -> Vec<WidgetId> {
810        self.root_child_id.into_iter().collect()
811    }
812
813    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
814        builder.set_role(Role::Group);
815        let name = self
816            .label
817            .as_ref()
818            .map(|ls| ls.resolve_now())
819            .unwrap_or_else(|| resolve_message_widget("color-picker-name", &[]));
820        builder.set_name(name);
821        builder.set_live(Live::Polite);
822        let hex = self
823            .last_announced_hex
824            .borrow()
825            .clone()
826            .unwrap_or_else(|| self.binding.value().get().to_hex_upper(self.alpha_enabled));
827        builder.set_value(resolve_message_widget(
828            "color-picker-changed-announcement",
829            &[("hex", hex.into())],
830        ));
831        // Framework a11y walker sets `set_disabled` from arena state.
832        builder.add_action(Action::Focus);
833    }
834}
835
836fn resolve_color_picker_style(
837    override_: &Option<teksilo_core::styles::SharedColorPickerStyle>,
838    ctx: &BuildContext,
839) -> teksilo_core::styles::SharedColorPickerStyle {
840    if let Some(s) = override_.clone() {
841        return s;
842    }
843    ctx.theme_signal()
844        .get()
845        .style_slots
846        .color_picker
847        .clone()
848        .unwrap_or_else(|| {
849            Rc::new(
850                crate::styles::recipe_color_picker_style::RecipeColorPickerStyle::for_tokens(
851                    &ctx.theme().input,
852                ),
853            ) as teksilo_core::styles::SharedColorPickerStyle
854        })
855}
856
857// ── Helpers ───────────────────────────────────────────────────────────
858
859/// Bridge a mutable `Signal<Color>` channel → `SpinBox<u8>` (0..255).
860/// Observes the mutable source signal so `ctx.effect` works (derived
861/// signals are ReadOnly).
862fn make_byte_spinner_from_value(
863    ctx: &mut BuildContext,
864    value: Signal<Color>,
865    accessor: fn(Color) -> f32,
866    setter: Rc<dyn Fn(f32)>,
867    enabled: bool,
868    width: f32,
869) -> SpinBox<u8> {
870    let initial = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
871    let bridge = ctx.signal(initial);
872    // value → bridge
873    {
874        let bridge = bridge.clone();
875        ctx.effect(&value, move |c| {
876            let new_u = (accessor(*c) * 255.0).round().clamp(0.0, 255.0) as u8;
877            if bridge.get() != new_u {
878                bridge.set(new_u);
879            }
880        });
881    }
882    // bridge → setter — guard against re-entrance from the value→bridge
883    // effect by no-oping when the current value's projection already
884    // equals the bridge value (i.e. this bridge change came from a
885    // value-driven update, not user input on the SpinBox).
886    {
887        let setter = setter.clone();
888        let value = value.clone();
889        ctx.effect(&bridge, move |new_u| {
890            let current_u = (accessor(value.get()) * 255.0).round().clamp(0.0, 255.0) as u8;
891            if *new_u == current_u {
892                return;
893            }
894            (setter)((*new_u) as f32 / 255.0);
895        });
896    }
897    SpinBox::new(bridge, 0u8, 255u8)
898        .single_step(1u8)
899        .page_step(16u8)
900        .enabled(enabled)
901        .width(width)
902}
903
904/// Bridge `Signal<Color>` (HSV hue) → `SpinBox<u32>` (0..359).
905fn make_hue_spinner_from_value(
906    ctx: &mut BuildContext,
907    value: Signal<Color>,
908    setter: Rc<dyn Fn(f32)>,
909    enabled: bool,
910    width: f32,
911) -> SpinBox<u32> {
912    let initial = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
913    let bridge = ctx.signal(initial);
914    {
915        let bridge = bridge.clone();
916        ctx.effect(&value, move |c| {
917            let new_u = c.to_hsv().0.round().clamp(0.0, 359.0) as u32;
918            if bridge.get() != new_u {
919                bridge.set(new_u);
920            }
921        });
922    }
923    {
924        let setter = setter.clone();
925        let value = value.clone();
926        ctx.effect(&bridge, move |new_u| {
927            let current_u = value.get().to_hsv().0.round().clamp(0.0, 359.0) as u32;
928            if *new_u == current_u {
929                return;
930            }
931            (setter)(*new_u as f32);
932        });
933    }
934    SpinBox::new(bridge, 0u32, 359u32)
935        .single_step(1u32)
936        .page_step(15u32)
937        .enabled(enabled)
938        .width(width)
939}
940
941/// Bridge `Signal<Color>` (HSV channel) → `SpinBox<u8>` displayed as 0..100 percent.
942fn make_percent_spinner_from_value(
943    ctx: &mut BuildContext,
944    value: Signal<Color>,
945    accessor: fn(Color) -> f32,
946    setter: Rc<dyn Fn(f32)>,
947    enabled: bool,
948    width: f32,
949) -> SpinBox<u8> {
950    let initial = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
951    let bridge = ctx.signal(initial);
952    {
953        let bridge = bridge.clone();
954        ctx.effect(&value, move |c| {
955            let new_u = (accessor(*c) * 100.0).round().clamp(0.0, 100.0) as u8;
956            if bridge.get() != new_u {
957                bridge.set(new_u);
958            }
959        });
960    }
961    {
962        let setter = setter.clone();
963        let value = value.clone();
964        ctx.effect(&bridge, move |new_u| {
965            let current_u = (accessor(value.get()) * 100.0).round().clamp(0.0, 100.0) as u8;
966            if *new_u == current_u {
967                return;
968            }
969            (setter)((*new_u) as f32 / 100.0);
970        });
971    }
972    SpinBox::new(bridge, 0u8, 100u8)
973        .single_step(1u8)
974        .page_step(10u8)
975        .suffix(" %")
976        .enabled(enabled)
977        .width(width)
978}
979
980/// Wrap a spinner with a small leading label cell ("R", "G", "B", …).
981fn spinner_cell(label_key: &str, spinner: impl Widget + 'static) -> HStack {
982    let label = resolve_message_widget(label_key, &[]);
983    HStack::new()
984        .spacing(4.0)
985        .child(TextWidget::new(lit!(label)))
986        .child(spinner)
987}