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