Skip to main content

teksilo_widgets/color_picker/
swatch.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `ColorSwatch` — single clickable color cell with `Role::ColorWell`.
5//!
6//! Public widget so apps can compose their own swatch rows or palettes
7//! outside of the bundled `SwatchGrid`. Renders an optional checkerboard
8//! underlay when `color.a() < 1.0` so transparent swatches read correctly.
9//! The displayed color is a `Prop<Color>` — pass a static `Color` for a
10//! fixed palette entry or a `Signal<Color>` for a live preview that
11//! re-paints whenever the bound value changes (used by `ColorPicker`'s
12//! current-color preview and `ColorEdit`'s trigger swatch).
13//!
14//! ## Accessibility
15//!
16//! Declares `Role::ColorWell`; `set_color_value` carries the RGBA value
17//! and `set_value` carries the formatted hex string so braille and
18//! voice output both have a human-readable form. Selected swatches
19//! append a localized "selected" suffix to their announced name.
20//!
21//! ```rust
22//! # use teksilo_widgets::color_picker::ColorSwatch;
23//! # use teksilo_tokens::Color;
24//! let _swatch = ColorSwatch::new(Color::new(0.21, 0.52, 0.89, 1.0))
25//!     .size(24.0)
26//!     .corner_radius(4.0);
27//! ```
28
29use std::cell::Cell;
30use std::rc::Rc;
31
32use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::accesskit::{Action, Role};
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::event::{EventResponse, Key, WidgetEvent};
37use teksilo_core::focus::FocusOrigin;
38use teksilo_core::widget::{
39    CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
40};
41use teksilo_core::widget_builder::HandlerSet;
42use teksilo_core::widget_id::WidgetId;
43use teksilo_i18n::{LocalizedString, resolve_message_widget};
44use teksilo_tokens::{Color, CornerRadius};
45
46use super::alpha_strip::paint_checkerboard;
47
48type ActivateFn = Rc<dyn Fn(&mut EventContext)>;
49
50/// Single-cell color swatch.
51///
52/// The displayed color is a `Prop<Color>` — pass a `Color` for a
53/// static palette entry (the common case in `SwatchGrid`) or a
54/// `Signal<Color>` for a live preview that re-paints when the bound
55/// value changes (used by `ColorPicker`'s current-color preview and
56/// `ColorEdit`'s trigger).
57pub struct ColorSwatch {
58    color: teksilo_core::signal::Prop<Color>,
59    selected: bool,
60    label: Option<LocalizedString>,
61    size: Option<f32>,
62    corner_radius: Option<f32>,
63    /// Enabled state, static or reactive; forwarded to the arena at
64    /// build time.
65    enabled: teksilo_core::signal::Prop<bool>,
66    on_activate: Option<ActivateFn>,
67    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
68    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
69    /// with the rich / composite slots — every setter clears the other two so
70    /// the last call wins.
71    tooltip_text: Option<LocalizedString>,
72    /// Optional rich tooltip source (registry key or inline content).
73    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
74    /// Optional composite tooltip body (arbitrary widget tree).
75    composite_tooltip_content: Option<Box<dyn Widget>>,
76}
77
78impl ColorSwatch {
79    /// Create a swatch displaying `color`. Accepts a static `Color` or a
80    /// `Signal<Color>` (via `impl Into<Prop<Color>>`); a reactive value
81    /// re-paints the cell whenever the signal changes.
82    pub fn new(color: impl Into<teksilo_core::signal::Prop<Color>>) -> Self {
83        Self {
84            color: color.into(),
85            selected: false,
86            label: None,
87            size: None,
88            corner_radius: None,
89            enabled: teksilo_core::signal::Prop::Static(true),
90            on_activate: None,
91            focus_origin: Rc::new(Cell::new(None)),
92            tooltip_text: None,
93            rich_tooltip_source: None,
94            composite_tooltip_content: None,
95        }
96    }
97
98    /// Mark the swatch as currently selected, which paints an accent
99    /// border and appends a localized "selected" suffix to the AT name.
100    pub fn selected(mut self, selected: bool) -> Self {
101        self.selected = selected;
102        self
103    }
104
105    /// Override the accessible label. Default is a localized "Color: #RRGGBB"
106    /// string derived from the displayed color's hex value.
107    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
108        self.label = Some(label.into());
109        self
110    }
111
112    /// Set the swatch cell size in logical pixels (square). Defaults to
113    /// the theme's `recipe_color_picker_style::SWATCH_SIZE`.
114    pub fn size(mut self, size: f32) -> Self {
115        self.size = Some(size.max(0.0));
116        self
117    }
118
119    /// Set the corner radius of the swatch cell in logical pixels.
120    /// Defaults to `recipe_color_picker_style::SWATCH_CORNER_RADIUS`.
121    pub fn corner_radius(mut self, r: f32) -> Self {
122        self.corner_radius = Some(r.max(0.0));
123        self
124    }
125
126    /// Set the enabled state, statically or reactively. Forwarded to the
127    /// arena at build time.
128    pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
129        self.enabled = enabled.into();
130        self
131    }
132
133    /// Register an activation callback invoked on tap, Enter, Space, or
134    /// the `Action::Click` accessibility action.
135    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
136        self.on_activate = Some(Rc::new(f));
137        self
138    }
139
140    /// Attach a plain single-line tooltip shown after a hover delay.
141    ///
142    /// Mutually exclusive with [`Self::rich_tooltip`], [`Self::rich_tooltip_content`],
143    /// and [`Self::composite_tooltip`] — this call clears the other slots.
144    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
145        self.tooltip_text = Some(text.into());
146        self.rich_tooltip_source = None;
147        self.composite_tooltip_content = None;
148        self
149    }
150
151    /// Attach a rich tooltip looked up from the tooltip registry by key.
152    ///
153    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip_content`],
154    /// and [`Self::composite_tooltip`] — this call clears the other slots.
155    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
156        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
157        self.tooltip_text = None;
158        self.composite_tooltip_content = None;
159        self
160    }
161
162    /// Attach a rich tooltip with inline content (no registry lookup required).
163    ///
164    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
165    /// and [`Self::composite_tooltip`] — this call clears the other slots.
166    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
167        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
168        self.tooltip_text = None;
169        self.composite_tooltip_content = None;
170        self
171    }
172
173    /// Attach a composite tooltip whose body is an arbitrary widget tree.
174    ///
175    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
176    /// and [`Self::rich_tooltip_content`] — this call clears the other slots.
177    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
178        self.composite_tooltip_content = Some(Box::new(content));
179        self.tooltip_text = None;
180        self.rich_tooltip_source = None;
181        self
182    }
183}
184
185impl std::fmt::Debug for ColorSwatch {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("ColorSwatch")
188            .field("color", &self.color.get())
189            .field("selected", &self.selected)
190            .field("enabled", &self.enabled.get())
191            .finish_non_exhaustive()
192    }
193}
194
195impl Widget for ColorSwatch {
196    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
197        let self_id = ctx.self_id();
198        // Forward the enabled state into the arena; see IconButton.
199        ctx.enabled_when(self_id, self.enabled.clone());
200        let on_activate = self.on_activate.clone();
201        // Framework gates events on `arena.is_enabled` and the focus
202        // walker skips disabled subtrees.
203        let mut handlers = HandlerSet::new()
204            .focusable(true)
205            .cursor(CursorIcon::Pointer);
206
207        if let Some(cb) = on_activate.clone() {
208            handlers = handlers.on_tap(move |_pos, ctx_evt| {
209                cb(ctx_evt);
210            });
211        }
212        if let Some(cb) = on_activate.clone() {
213            handlers = handlers.on_key(move |event, ctx_evt| {
214                let WidgetEvent::KeyDown { key, .. } = event else {
215                    return EventResponse::Ignored;
216                };
217                match key {
218                    Key::Enter | Key::Space => {
219                        cb(ctx_evt);
220                        EventResponse::Handled
221                    }
222                    _ => EventResponse::Ignored,
223                }
224            });
225        }
226        if let Some(cb) = on_activate {
227            handlers = handlers.on_access_action(move |action, ctx_evt| match action {
228                Action::Click => {
229                    cb(ctx_evt);
230                    EventResponse::Handled
231                }
232                _ => EventResponse::Ignored,
233            });
234        }
235
236        {
237            let focus_origin = self.focus_origin.clone();
238            handlers = handlers.on_focus(move |gained, _ctx| {
239                focus_origin.set(if gained {
240                    Some(FocusOrigin::Keyboard)
241                } else {
242                    None
243                });
244            });
245        }
246
247        ctx.apply_self_handlers(handlers);
248
249        // Tooltip attachment — mutually exclusive slots, last setter wins.
250        if let Some(content) = self.composite_tooltip_content.take() {
251            let delay = ctx.theme().motion.tooltip_delay_heavy;
252            crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
253        } else if let Some(source) = self.rich_tooltip_source.clone() {
254            let delay = ctx.theme().motion.tooltip_delay;
255            crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
256        } else if let Some(text) = self.tooltip_text.clone() {
257            let delay = ctx.theme().motion.tooltip_delay;
258            crate::tooltip::attach_plain_tooltip(ctx, self_id, text, delay);
259        }
260
261        // Reactive: when `color` is bound to a Signal, re-paint and
262        // refresh the AT color value whenever it changes. Static
263        // colors register nothing (Prop::Static).
264        let self_id = ctx.self_id();
265        let registry = ctx.binding_registry();
266        self.color.register_if_bound(
267            self_id,
268            registry,
269            teksilo_core::binding::BindingLevel::AccessibilityOnly,
270        );
271        self.color.register_if_bound(
272            self_id,
273            registry,
274            teksilo_core::binding::BindingLevel::RepaintOnly,
275        );
276
277        Vec::new()
278    }
279
280    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
281        use crate::styles::recipe_color_picker_style as cp;
282        let size = self.size.unwrap_or(cp::SWATCH_SIZE);
283        Size::new(size, size).into()
284    }
285
286    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
287        use crate::styles::recipe_color_picker_style as cp;
288        let radius = CornerRadius::uniform(self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS));
289        let color = self.color.get();
290
291        // Checkerboard underlay if the swatch is partly transparent.
292        if color.a() < 1.0 {
293            paint_checkerboard(
294                canvas,
295                bounds,
296                cp::CHECKER_CELL,
297                cp::CHECKER_COLOR_A,
298                cp::CHECKER_COLOR_B,
299            );
300        }
301
302        canvas.fill_rounded_rect(bounds, radius, color);
303
304        // Selection ring.
305        if self.selected {
306            canvas.stroke_rounded_rect(
307                bounds,
308                radius,
309                ctx.theme.colors.accent,
310                cp::SWATCH_SELECTED_STROKE_WIDTH,
311            );
312        } else {
313            // Always draw a hairline border so light swatches don't
314            // disappear into a light surface.
315            canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
316        }
317
318        // Focus ring (keyboard).
319        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
320            let offset = ctx.theme.shape.focus_ring_offset;
321            let half = ctx.theme.shape.focus_ring_width * 0.5;
322            let inset = offset + half;
323            let ring = Rect::new(
324                bounds.x - inset,
325                bounds.y - inset,
326                bounds.width + inset * 2.0,
327                bounds.height + inset * 2.0,
328            );
329            canvas.stroke_rounded_rect(
330                ring,
331                CornerRadius::uniform(
332                    self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS) + inset,
333                ),
334                ctx.theme.colors.focus_ring,
335                ctx.theme.shape.focus_ring_width,
336            );
337        }
338    }
339
340    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
341        builder.set_role(Role::ColorWell);
342        let color = self.color.get();
343        builder.set_color_value(color);
344        let hex = color.to_hex_upper(color.a() < 1.0);
345        let name = match &self.label {
346            Some(ls) => ls.resolve_now(),
347            None => {
348                resolve_message_widget("color-picker-swatch-label", &[("hex", hex.clone().into())])
349            }
350        };
351        let display = if self.selected {
352            let suffix = resolve_message_widget("color-picker-swatch-selected-suffix", &[]);
353            format!("{}{}", name, suffix)
354        } else {
355            name
356        };
357        builder.set_name(display);
358        builder.set_value(hex);
359        if self.selected {
360            builder.set_selected(true);
361        }
362        // Framework a11y walker sets `set_disabled` from arena state.
363        builder.add_action(Action::Click);
364        builder.add_action(Action::Focus);
365    }
366
367    fn place_children(
368        &self,
369        _bounds: Rect,
370        _proposal: SizeProposal,
371        _children: &mut [WidgetPlacement],
372        _ctx: &LayoutContext,
373    ) {
374    }
375}