Skip to main content

teksilo_widgets/
color_edit.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ColorEdit` — compact field-style color picker trigger that opens
5//! a popover containing a [`ColorPicker`].
6//!
7//! Direct analog of [`DateEdit`](crate::date_edit::DateEdit). The
8//! trigger is a [`Button`] with a reactive [`ColorSwatch`] in its
9//! leading slot, the current hex as the label, and an optional
10//! chevron in its trailing slot. Click, Enter, Space, or Alt+Down
11//! opens the popover; Escape or click-outside closes it. The inner
12//! picker writes through the same bound `Signal<Color>`, so external
13//! observers see live updates as the user drags within the popover
14//! (no commit step).
15//!
16//! Built on [`PopoverButton`]:
17//! the overlay wiring (dormant content + show / dismiss + AT
18//! `has_popup` + `expanded`) lives there. This file is just the
19//! ColorEdit-specific assembly — picker config pass-through, the
20//! reactive trigger, and the nullable-binding bridge.
21//!
22//! # Accessibility
23//!
24//! The trigger declares `Role::Button`
25//! (via Button), `HasPopup::Dialog`
26//! (via PopoverButton), and tracks the popover open state through
27//! `set_expanded`. The label binds reactively to the hex value so
28//! AT name updates as the picker mutates the bound color.
29//!
30//! # Example
31//!
32//! ```ignore
33//! use teksilo_core::signal::Signal;
34//! use teksilo_tokens::Color;
35//!
36//! let color = ctx.signal(Color::new(0.21, 0.52, 0.89, 1.0));
37//! let _edit = ColorEdit::new(color)
38//!     .alpha_enabled(true)
39//!     .show_chevron(true);
40//! ```
41
42use std::rc::Rc;
43use teksilo_i18n::lit;
44
45use teksilo_canvas::{Rect, SizeProposal};
46use teksilo_core::accessibility::AccessNodeBuilder;
47use teksilo_core::build_context::BuildContext;
48use teksilo_core::overlay::{DismissBehavior, OverlayPlacement};
49use teksilo_core::signal::{Prop, Signal};
50use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
51use teksilo_core::widget_builder::WidgetBuilder;
52use teksilo_core::widget_id::WidgetId;
53use teksilo_i18n::{LocalizedString, resolve_message_widget};
54use teksilo_tokens::Color;
55
56use crate::button::Button;
57use crate::color_picker::{ColorPicker, ColorPickerLayout, ColorSwatch};
58use crate::popover_widget::PopoverButton;
59use crate::primitives::IconWidget;
60
61type OnVoid = Rc<dyn Fn()>;
62
63#[derive(Clone)]
64enum ColorBinding {
65    Required(Signal<Color>),
66    Nullable {
67        source: Signal<Option<Color>>,
68        proxy: Signal<Color>,
69    },
70}
71
72impl ColorBinding {
73    fn proxy(&self) -> Signal<Color> {
74        match self {
75            Self::Required(s) => s.clone(),
76            Self::Nullable { proxy, .. } => proxy.clone(),
77        }
78    }
79}
80
81/// Compact color cell that opens a full [`ColorPicker`] in a popover when activated.
82pub struct ColorEdit {
83    binding: ColorBinding,
84
85    // Picker pass-through.
86    alpha_enabled: bool,
87    swatches: Option<Prop<Vec<Color>>>,
88    swatch_columns: usize,
89    picker_layout: ColorPickerLayout,
90    show_rgb_spinners: bool,
91    show_hsv_spinners: bool,
92    show_hex_input: bool,
93
94    // Trigger appearance.
95    show_hex_in_trigger: bool,
96    show_chevron: bool,
97    trigger_swatch_size: Option<f32>,
98
99    // Popover.
100    placement: OverlayPlacement,
101    dismiss_behavior: DismissBehavior,
102
103    // Composite.
104    label: Option<LocalizedString>,
105    /// Enabled state, static or reactive; forwarded to the arena at
106    /// build time.
107    enabled: Prop<bool>,
108    on_open: Option<OnVoid>,
109    on_close: Option<OnVoid>,
110
111    // Tooltip slots (mutually exclusive; last setter wins).
112    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
113    /// with the rich / composite slots — every setter clears the other two so
114    /// the last call wins.
115    tooltip_text: Option<LocalizedString>,
116    /// Optional rich tooltip source (registry key or inline content).
117    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
118    /// Optional composite tooltip body (arbitrary widget tree).
119    composite_tooltip_content: Option<Box<dyn Widget>>,
120
121    // Internal state.
122    root_child_id: Option<WidgetId>,
123}
124
125impl std::fmt::Debug for ColorEdit {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("ColorEdit")
128            .field("alpha_enabled", &self.alpha_enabled)
129            .field("picker_layout", &self.picker_layout)
130            .field("enabled", &self.enabled.get())
131            .finish_non_exhaustive()
132    }
133}
134
135impl ColorEdit {
136    /// Bind to a non-nullable color signal. The trigger and the picker
137    /// both read from and write to the same signal.
138    pub fn new(value: Signal<Color>) -> Self {
139        Self::from_binding(ColorBinding::Required(value))
140    }
141
142    /// Bind to a nullable color signal. `None` is treated as transparent
143    /// black for picker math; any user interaction produces a concrete
144    /// `Some(color)`. To clear back to `None`, compose a separate
145    /// Clear button alongside the `ColorEdit`.
146    pub fn nullable(value: Signal<Option<Color>>) -> Self {
147        let proxy = Signal::new(value.get().unwrap_or(Color::TRANSPARENT));
148        Self::from_binding(ColorBinding::Nullable {
149            source: value,
150            proxy,
151        })
152    }
153
154    fn from_binding(binding: ColorBinding) -> Self {
155        Self {
156            binding,
157            alpha_enabled: false,
158            swatches: None,
159            swatch_columns: 6,
160            picker_layout: ColorPickerLayout::Compact,
161            show_rgb_spinners: true,
162            show_hsv_spinners: false,
163            show_hex_input: true,
164            show_hex_in_trigger: true,
165            show_chevron: true,
166            trigger_swatch_size: None,
167            placement: OverlayPlacement::BelowPreferred,
168            dismiss_behavior: DismissBehavior::EscapeOrClickOutside,
169            label: None,
170            enabled: Prop::Static(true),
171            on_open: None,
172            on_close: None,
173            tooltip_text: None,
174            rich_tooltip_source: None,
175            composite_tooltip_content: None,
176            root_child_id: None,
177        }
178    }
179
180    /// Enable or disable the alpha channel in the picker and the hex trigger label.
181    pub fn alpha_enabled(mut self, enabled: bool) -> Self {
182        self.alpha_enabled = enabled;
183        self
184    }
185
186    /// Provide a palette of preset swatches shown in the popover —
187    /// statically, or reactively via a bound `Signal<Vec<Color>>` so the
188    /// palette updates without reopening the popover.
189    pub fn swatches(mut self, s: impl Into<Prop<Vec<Color>>>) -> Self {
190        self.swatches = Some(s.into());
191        self
192    }
193
194    /// Number of columns in the preset swatch grid. Defaults to 6;
195    /// clamped to at least 1.
196    pub fn swatch_columns(mut self, n: usize) -> Self {
197        self.swatch_columns = n.max(1);
198        self
199    }
200
201    /// Select a popover layout variant — [`ColorPickerLayout::Compact`]
202    /// (default, minimal height) or `Standard` / `Wide` for richer controls.
203    pub fn picker_layout(mut self, l: ColorPickerLayout) -> Self {
204        self.picker_layout = l;
205        self
206    }
207
208    /// Show or hide the RGB (0–255) component spinners in the popover.
209    pub fn show_rgb_spinners(mut self, s: bool) -> Self {
210        self.show_rgb_spinners = s;
211        self
212    }
213
214    /// Show or hide the HSV (hue/saturation/value) component spinners in the popover.
215    pub fn show_hsv_spinners(mut self, s: bool) -> Self {
216        self.show_hsv_spinners = s;
217        self
218    }
219
220    /// Show or hide the hex string input in the popover.
221    pub fn show_hex_input(mut self, s: bool) -> Self {
222        self.show_hex_input = s;
223        self
224    }
225
226    /// Show or hide the formatted hex value as the trigger button label.
227    pub fn show_hex_in_trigger(mut self, s: bool) -> Self {
228        self.show_hex_in_trigger = s;
229        self
230    }
231
232    /// Show or hide the trailing chevron glyph on the trigger button.
233    pub fn show_chevron(mut self, s: bool) -> Self {
234        self.show_chevron = s;
235        self
236    }
237
238    /// Override the size of the color swatch thumbnail in the trigger button (logical pixels).
239    pub fn trigger_swatch_size(mut self, size: f32) -> Self {
240        self.trigger_swatch_size = Some(size.max(0.0));
241        self
242    }
243
244    /// Override where the popover appears relative to the trigger.
245    /// Default is [`OverlayPlacement::BelowPreferred`].
246    pub fn placement(mut self, p: OverlayPlacement) -> Self {
247        self.placement = p;
248        self
249    }
250
251    /// Override how the popover is dismissed. Default is
252    /// `DismissBehavior::EscapeOrClickOutside`.
253    pub fn dismiss_behavior(mut self, b: DismissBehavior) -> Self {
254        self.dismiss_behavior = b;
255        self
256    }
257
258    /// Replace the trigger button's visible label with a static localized
259    /// string. When set, the hex value is no longer displayed in the trigger
260    /// (combine with `.show_hex_in_trigger(false)` if needed).
261    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
262        self.label = Some(label.into());
263        self
264    }
265
266    /// Set the enabled state, statically or reactively. Forwarded to the
267    /// arena at build time.
268    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
269        self.enabled = enabled.into();
270        self
271    }
272
273    /// Install a callback fired when the color-picker popover opens.
274    ///
275    /// The signature is `Fn()` (no [`EventContext`](teksilo_core::widget::EventContext))
276    /// because `on_close` is invoked from the overlay-dismiss path,
277    /// which has no ctx in scope. To keep the open/close pair
278    /// symmetric, `on_open` matches. If you need ctx in a
279    /// color-editing-mode callback, attach an `on_tap` on a sibling
280    /// trigger that wakes the editor explicitly.
281    pub fn on_open(mut self, f: impl Fn() + 'static) -> Self {
282        self.on_open = Some(Rc::new(f));
283        self
284    }
285
286    /// Install a callback fired when the color-picker popover closes.
287    /// See [`on_open`](Self::on_open) for why this is `Fn()` and not
288    /// `Fn(&mut EventContext)`.
289    pub fn on_close(mut self, f: impl Fn() + 'static) -> Self {
290        self.on_close = Some(Rc::new(f));
291        self
292    }
293
294    /// Attach a plain single-line tooltip shown after a hover delay.
295    ///
296    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
297    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
298    /// [`composite_tooltip`](Self::composite_tooltip) — calling this
299    /// clears the other slots (last setter wins).
300    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
301        self.tooltip_text = Some(text.into());
302        self.rich_tooltip_source = None;
303        self.composite_tooltip_content = None;
304        self
305    }
306
307    /// Attach a rich tooltip identified by a registry key.
308    ///
309    /// Mutually exclusive with [`tooltip`](Self::tooltip),
310    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
311    /// [`composite_tooltip`](Self::composite_tooltip) — calling this
312    /// clears the other slots (last setter wins).
313    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
314        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
315        self.tooltip_text = None;
316        self.composite_tooltip_content = None;
317        self
318    }
319
320    /// Attach a rich tooltip from an inline [`TooltipContent`](crate::tooltip::TooltipContent) value.
321    ///
322    /// Mutually exclusive with [`tooltip`](Self::tooltip),
323    /// [`rich_tooltip`](Self::rich_tooltip), and
324    /// [`composite_tooltip`](Self::composite_tooltip) — calling this
325    /// clears the other slots (last setter wins).
326    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
327        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
328        self.tooltip_text = None;
329        self.composite_tooltip_content = None;
330        self
331    }
332
333    /// Attach a composite tooltip whose body is an arbitrary widget tree.
334    ///
335    /// Mutually exclusive with [`tooltip`](Self::tooltip),
336    /// [`rich_tooltip`](Self::rich_tooltip), and
337    /// [`rich_tooltip_content`](Self::rich_tooltip_content) — calling
338    /// this clears the other slots (last setter wins).
339    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
340        self.composite_tooltip_content = Some(Box::new(content));
341        self.tooltip_text = None;
342        self.rich_tooltip_source = None;
343        self
344    }
345}
346
347impl Widget for ColorEdit {
348    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
349        let self_id = ctx.self_id();
350        // Forward the enabled state into the arena; see IconButton.
351        ctx.enabled_when(self_id, self.enabled.clone());
352
353        // Bridge nullable binding ↔ proxy. The picker writes the
354        // proxy; we mirror that to the source as Some(c). External
355        // changes to source flow back into proxy. Empty (None) state
356        // is purely visual on the trigger — there is no in-trigger
357        // "clear" affordance (apps compose a separate Clear button).
358        if let ColorBinding::Nullable { source, proxy } = &self.binding {
359            {
360                let proxy = proxy.clone();
361                ctx.effect(source, move |new| {
362                    let resolved = new.unwrap_or(Color::TRANSPARENT);
363                    if proxy.get() != resolved {
364                        proxy.set(resolved);
365                    }
366                });
367            }
368            {
369                let source = source.clone();
370                ctx.effect(proxy, move |c| {
371                    if source.get() != Some(*c) {
372                        source.set(Some(*c));
373                    }
374                });
375            }
376        }
377
378        let value = self.binding.proxy();
379        let alpha_enabled = self.alpha_enabled;
380        use crate::styles::recipe_color_picker_style as cp;
381
382        // Snapshot of the bound color at popover-open time. Cancel
383        // restores this; Done leaves the picker's writes intact. The
384        // initial value seeds the snapshot for the first open before
385        // the open-transition effect has a chance to refresh it.
386        let snapshot = ctx.signal(value.get());
387
388        // ── Build the picker (handed to PopoverButton as content) ──
389        let mut picker = ColorPicker::new(value.clone())
390            .alpha_enabled(alpha_enabled)
391            .layout(self.picker_layout)
392            .show_rgb_spinners(self.show_rgb_spinners)
393            .show_hsv_spinners(self.show_hsv_spinners)
394            .show_hex_input(self.show_hex_input)
395            .swatch_columns(self.swatch_columns)
396            .show_footer(true)
397            .on_done(|ctx_evt| {
398                ctx_evt.dismiss_self_overlay_chain();
399            })
400            .on_cancel({
401                let value = value.clone();
402                let snapshot = snapshot.clone();
403                move |ctx_evt| {
404                    let prior = snapshot.get();
405                    if value.get() != prior {
406                        value.set(prior);
407                    }
408                    ctx_evt.dismiss_self_overlay_chain();
409                }
410            });
411        if let Some(s) = self.swatches.clone() {
412            picker = picker.swatches(s);
413        }
414
415        // ── Build the trigger ──
416        let swatch_size = self.trigger_swatch_size.unwrap_or(cp::PREVIEW_HEIGHT);
417
418        // ColorSwatch accepts `impl Into<Prop<Color>>` — pass the
419        // bound signal so it re-paints whenever the picker mutates
420        // the value. `.access_hidden(true)` so the swatch's own
421        // ColorWell role doesn't appear as a redundant child of the
422        // trigger Button's Role::Button.
423        let swatch = ColorSwatch::new(value.clone())
424            .size(swatch_size)
425            .corner_radius(cp::PREVIEW_CORNER_RADIUS)
426            .enabled(false)
427            .access_hidden(true);
428
429        // Reactive hex / placeholder for the Button label. For the
430        // nullable variant, None → localized "—" placeholder; Some →
431        // formatted hex. For the required variant, just formatted hex.
432        let label_signal = match &self.binding {
433            ColorBinding::Required(s) => {
434                let alpha = alpha_enabled;
435                if self.show_hex_in_trigger {
436                    s.map(move |c| c.to_hex_upper(alpha))
437                } else {
438                    s.map(|_| String::new())
439                }
440            }
441            ColorBinding::Nullable { source, .. } => {
442                let alpha = alpha_enabled;
443                let show_hex = self.show_hex_in_trigger;
444                // Zip the locale signal so the "no color" placeholder
445                // re-resolves on a live locale switch — `source.map` alone
446                // only re-fires when the color changes.
447                source
448                    .zip(&ctx.locale_signal())
449                    .map(move |(opt, _)| match opt {
450                        Some(c) if show_hex => c.to_hex_upper(alpha),
451                        Some(_) => String::new(),
452                        None => resolve_message_widget("color-edit-trigger-empty-placeholder", &[]),
453                    })
454            }
455        };
456
457        // App-supplied `.label(...)` replaces the entire visible text
458        // (and therefore the AT name) with a static localized string.
459        // Apps that want their custom label PLUS a visible swatch can
460        // pair `.label(...)` with `.show_hex_in_trigger(false)`. When
461        // no label is set, the bound hex signal feeds the Button label
462        // — every value mutation refreshes the visible text and the
463        // AT name reactively via Button's `label` plumbing.
464        let trigger = if let Some(ls) = self.label.take() {
465            Button::new(ls)
466        } else {
467            Button::new(lit!("")).label(label_signal)
468        };
469        let mut trigger = trigger.leading(swatch);
470        if self.show_chevron {
471            trigger = trigger.trailing(IconWidget::chevron_down(12.0).access_hidden(true));
472        }
473
474        // ── Wrap in PopoverButton ──
475        let pb = PopoverButton::new(trigger)
476            .content(picker)
477            .placement(self.placement.clone())
478            .dismiss_behavior(self.dismiss_behavior.clone());
479
480        // Refresh the cancel-snapshot whenever the popover transitions
481        // to open. This must run BEFORE the user has a chance to
482        // mutate the value through the picker — `open_signal()` flips
483        // synchronously inside the activate handler, before any drag
484        // events reach the canvas / strips, so the snapshot captures
485        // the value that was bound at the moment of open.
486        {
487            let open_signal = pb.open_signal();
488            let snapshot = snapshot.clone();
489            let value = value.clone();
490            ctx.effect(&open_signal, move |opened| {
491                if *opened {
492                    let current = value.get();
493                    if snapshot.get() != current {
494                        snapshot.set(current);
495                    }
496                }
497            });
498        }
499
500        let mut pb = pb;
501        if let Some(cb) = self.on_open.take() {
502            pb = pb.on_open(move || cb());
503        }
504        if let Some(cb) = self.on_close.take() {
505            pb = pb.on_close(move || cb());
506        }
507
508        let pb_id = ctx.add(pb);
509        self.root_child_id = Some(pb_id);
510
511        // Tooltip attachment — anchored on the trigger (pb_id), not the popover content.
512        if let Some(content) = self.composite_tooltip_content.take() {
513            let delay = ctx.theme().motion.tooltip_delay_heavy;
514            crate::tooltip::attach_composite_tooltip_boxed(ctx, pb_id, content, delay);
515        } else if let Some(source) = self.rich_tooltip_source.clone() {
516            let delay = ctx.theme().motion.tooltip_delay;
517            crate::tooltip::attach_rich_tooltip_source(ctx, pb_id, source, delay);
518        } else if let Some(text) = self.tooltip_text.clone() {
519            let delay = ctx.theme().motion.tooltip_delay;
520            crate::tooltip::attach_plain_tooltip(ctx, pb_id, text, delay);
521        }
522
523        vec![pb_id]
524    }
525
526    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
527        match self.root_child_id {
528            Some(id) => ctx
529                .child_layout_response(id, proposal)
530                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
531            None => proposal.resolve(0.0, 0.0).into(),
532        }
533    }
534
535    fn place_children(
536        &self,
537        bounds: Rect,
538        _proposal: SizeProposal,
539        children: &mut [WidgetPlacement],
540        _ctx: &LayoutContext,
541    ) {
542        for child in children.iter_mut() {
543            child.origin = bounds.origin();
544            child.size = bounds.size();
545        }
546    }
547
548    fn children(&self) -> Vec<WidgetId> {
549        self.root_child_id.into_iter().collect()
550    }
551
552    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
553        // Transparent — the inner Button (via PopoverButton) declares
554        // Role::Button + has_popup + expanded + name. Adding anything
555        // here would create a duplicate AT element above the trigger.
556    }
557}