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::styles::FocusRingStroke;
39use teksilo_core::widget::{
40    CursorIcon, EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
41};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_i18n::{LocalizedString, resolve_message_widget};
45use teksilo_tokens::{Color, CornerRadius};
46
47use super::alpha_strip::paint_checkerboard;
48
49type ActivateFn = Rc<dyn Fn(&mut EventContext)>;
50
51/// Single-cell color swatch.
52///
53/// The displayed color is a `Prop<Color>` — pass a `Color` for a
54/// static palette entry (the common case in `SwatchGrid`) or a
55/// `Signal<Color>` for a live preview that re-paints when the bound
56/// value changes (used by `ColorPicker`'s current-color preview and
57/// `ColorEdit`'s trigger).
58pub struct ColorSwatch {
59    color: teksilo_core::signal::Prop<Color>,
60    selected: bool,
61    label: Option<LocalizedString>,
62    size: Option<f32>,
63    corner_radius: Option<f32>,
64    /// Enabled state, static or reactive; forwarded to the arena at
65    /// build time.
66    enabled: teksilo_core::signal::Prop<bool>,
67    on_activate: Option<ActivateFn>,
68    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
69    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
70    /// with the rich / composite slots — every setter clears the other two so
71    /// the last call wins.
72    tooltip_text: Option<LocalizedString>,
73    /// Optional rich tooltip source (registry key or inline content).
74    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
75    /// Optional composite tooltip body (arbitrary widget tree).
76    composite_tooltip_content: Option<Box<dyn Widget>>,
77}
78
79impl ColorSwatch {
80    /// Create a swatch displaying `color`. Accepts a static `Color` or a
81    /// `Signal<Color>` (via `impl Into<Prop<Color>>`); a reactive value
82    /// re-paints the cell whenever the signal changes.
83    pub fn new(color: impl Into<teksilo_core::signal::Prop<Color>>) -> Self {
84        Self {
85            color: color.into(),
86            selected: false,
87            label: None,
88            size: None,
89            corner_radius: None,
90            enabled: teksilo_core::signal::Prop::Static(true),
91            on_activate: None,
92            focus_origin: Rc::new(Cell::new(None)),
93            tooltip_text: None,
94            rich_tooltip_source: None,
95            composite_tooltip_content: None,
96        }
97    }
98
99    /// Mark the swatch as currently selected, which paints an accent
100    /// border and appends a localized "selected" suffix to the AT name.
101    pub fn selected(mut self, selected: bool) -> Self {
102        self.selected = selected;
103        self
104    }
105
106    /// Override the accessible label. Default is a localized "Color: #RRGGBB"
107    /// string derived from the displayed color's hex value.
108    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
109        self.label = Some(label.into());
110        self
111    }
112
113    /// Set the swatch cell size in logical pixels (square). Defaults to
114    /// the theme's `recipe_color_picker_style::SWATCH_SIZE`.
115    pub fn size(mut self, size: f32) -> Self {
116        self.size = Some(size.max(0.0));
117        self
118    }
119
120    /// Set the corner radius of the swatch cell in logical pixels.
121    /// Defaults to `recipe_color_picker_style::SWATCH_CORNER_RADIUS`.
122    pub fn corner_radius(mut self, r: f32) -> Self {
123        self.corner_radius = Some(r.max(0.0));
124        self
125    }
126
127    /// Set the enabled state, statically or reactively. Forwarded to the
128    /// arena at build time.
129    pub fn enabled(mut self, enabled: impl Into<teksilo_core::signal::Prop<bool>>) -> Self {
130        self.enabled = enabled.into();
131        self
132    }
133
134    /// Register an activation callback invoked on tap, Enter, Space, or
135    /// the `Action::Click` accessibility action.
136    pub fn on_activate_fn(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
137        self.on_activate = Some(Rc::new(f));
138        self
139    }
140
141    /// Attach a plain single-line tooltip shown after a hover delay.
142    ///
143    /// Mutually exclusive with [`Self::rich_tooltip`], [`Self::rich_tooltip_content`],
144    /// and [`Self::composite_tooltip`] — this call clears the other slots.
145    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
146        self.tooltip_text = Some(text.into());
147        self.rich_tooltip_source = None;
148        self.composite_tooltip_content = None;
149        self
150    }
151
152    /// Attach a rich tooltip looked up from the tooltip registry by key.
153    ///
154    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip_content`],
155    /// and [`Self::composite_tooltip`] — this call clears the other slots.
156    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
157        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
158        self.tooltip_text = None;
159        self.composite_tooltip_content = None;
160        self
161    }
162
163    /// Attach a rich tooltip with inline content (no registry lookup required).
164    ///
165    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
166    /// and [`Self::composite_tooltip`] — this call clears the other slots.
167    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
168        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
169        self.tooltip_text = None;
170        self.composite_tooltip_content = None;
171        self
172    }
173
174    /// Attach a composite tooltip whose body is an arbitrary widget tree.
175    ///
176    /// Mutually exclusive with [`Self::tooltip`], [`Self::rich_tooltip`],
177    /// and [`Self::rich_tooltip_content`] — this call clears the other slots.
178    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
179        self.composite_tooltip_content = Some(Box::new(content));
180        self.tooltip_text = None;
181        self.rich_tooltip_source = None;
182        self
183    }
184}
185
186impl std::fmt::Debug for ColorSwatch {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("ColorSwatch")
189            .field("color", &self.color.get())
190            .field("selected", &self.selected)
191            .field("enabled", &self.enabled.get())
192            .finish_non_exhaustive()
193    }
194}
195
196impl Widget for ColorSwatch {
197    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
198        let self_id = ctx.self_id();
199        // Forward the enabled state into the arena; see IconButton.
200        ctx.enabled_when(self_id, self.enabled.clone());
201        let on_activate = self.on_activate.clone();
202        // Framework gates events on `arena.is_enabled` and the focus
203        // walker skips disabled subtrees.
204        let mut handlers = HandlerSet::new()
205            .focusable(true)
206            .cursor(CursorIcon::Pointer);
207
208        if let Some(cb) = on_activate.clone() {
209            handlers = handlers.on_tap(move |_pos, ctx_evt| {
210                cb(ctx_evt);
211            });
212        }
213        if let Some(cb) = on_activate.clone() {
214            handlers = handlers.on_key(move |event, ctx_evt| {
215                let WidgetEvent::KeyDown { key, .. } = event else {
216                    return EventResponse::Ignored;
217                };
218                match key {
219                    Key::Enter | Key::Space => {
220                        cb(ctx_evt);
221                        EventResponse::Handled
222                    }
223                    _ => EventResponse::Ignored,
224                }
225            });
226        }
227        if let Some(cb) = on_activate {
228            handlers = handlers.on_access_action(move |action, ctx_evt| match action {
229                Action::Click => {
230                    cb(ctx_evt);
231                    EventResponse::Handled
232                }
233                _ => EventResponse::Ignored,
234            });
235        }
236
237        {
238            let focus_origin = self.focus_origin.clone();
239            handlers = handlers.on_focus(move |gained, _ctx| {
240                focus_origin.set(if gained {
241                    Some(FocusOrigin::Keyboard)
242                } else {
243                    None
244                });
245            });
246        }
247
248        ctx.apply_self_handlers(handlers);
249
250        // Tooltip attachment — mutually exclusive slots, last setter wins.
251        if let Some(content) = self.composite_tooltip_content.take() {
252            let delay = ctx.theme().motion.tooltip_delay_heavy;
253            crate::tooltip::attach_composite_tooltip_boxed(ctx, self_id, content, delay);
254        } else if let Some(source) = self.rich_tooltip_source.clone() {
255            let delay = ctx.theme().motion.tooltip_delay;
256            crate::tooltip::attach_rich_tooltip_source(ctx, self_id, source, delay);
257        } else if let Some(text) = self.tooltip_text.clone() {
258            let delay = ctx.theme().motion.tooltip_delay;
259            crate::tooltip::attach_plain_tooltip(ctx, self_id, text, delay);
260        }
261
262        // Reactive: when `color` is bound to a Signal, re-paint and
263        // refresh the AT color value whenever it changes. Static
264        // colors register nothing (Prop::Static).
265        let self_id = ctx.self_id();
266        let registry = ctx.binding_registry();
267        self.color.register_if_bound(
268            self_id,
269            registry,
270            teksilo_core::binding::BindingLevel::AccessibilityOnly,
271        );
272        self.color.register_if_bound(
273            self_id,
274            registry,
275            teksilo_core::binding::BindingLevel::RepaintOnly,
276        );
277
278        Vec::new()
279    }
280
281    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
282        use crate::styles::recipe_color_picker_style as cp;
283        let size = self.size.unwrap_or(cp::SWATCH_SIZE);
284        Size::new(size, size).into()
285    }
286
287    /// A 22 dp swatch is 2 dp under the conformance floor, and a swatch grid is
288    /// a wall of them: the neighbours are targets too, so the miss-only slop
289    /// pass — which only re-attributes to a candidate strictly closer than the
290    /// bubble owner — has nothing to work with. The outset makes the shortfall
291    /// up inside the exact pass, and where two neighbours' rings meet in the
292    /// gap between them the arena resolves by distance, so the boundary sits
293    /// halfway.
294    ///
295    /// Zero for a swatch that takes no press: a widened node that then ignores
296    /// it is a hole punched in the grid behind it.
297    fn hit_outset(
298        &self,
299        kind: teksilo_tokens::PointerKind,
300        tokens: &teksilo_tokens::InputTokens,
301    ) -> teksilo_canvas::EdgeInsets {
302        use crate::styles::recipe_color_picker_style as cp;
303        if self.on_activate.is_none() || !self.enabled.get() {
304            return teksilo_canvas::EdgeInsets::ZERO;
305        }
306        let size = self.size.unwrap_or(cp::SWATCH_SIZE);
307        crate::button::target_outset(Size::new(size, size), kind, tokens)
308    }
309
310    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
311        use crate::styles::recipe_color_picker_style as cp;
312        let radius = CornerRadius::uniform(self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS));
313        let color = self.color.get();
314
315        // Checkerboard underlay if the swatch is partly transparent.
316        if color.a() < 1.0 {
317            paint_checkerboard(
318                canvas,
319                bounds,
320                cp::CHECKER_CELL,
321                cp::CHECKER_COLOR_A,
322                cp::CHECKER_COLOR_B,
323            );
324        }
325
326        canvas.fill_rounded_rect(bounds, radius, color);
327
328        // Selection ring.
329        if self.selected {
330            canvas.stroke_rounded_rect(
331                bounds,
332                radius,
333                ctx.theme.colors.accent,
334                cp::SWATCH_SELECTED_STROKE_WIDTH,
335            );
336        } else {
337            // Always draw a hairline border so light swatches don't
338            // disappear into a light surface.
339            canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
340        }
341
342        // Focus ring (keyboard).
343        if self.focus_origin.get() == Some(FocusOrigin::Keyboard) {
344            let ring = FocusRingStroke::around(
345                bounds,
346                self.corner_radius.unwrap_or(cp::SWATCH_CORNER_RADIUS),
347                &ctx.theme.shape,
348            );
349            canvas.stroke_rounded_rect(
350                ring.rect,
351                CornerRadius::uniform(ring.corner_radius),
352                ctx.theme.colors.focus_ring,
353                ctx.theme.shape.focus_ring_width,
354            );
355        }
356    }
357
358    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
359        builder.set_role(Role::ColorWell);
360        let color = self.color.get();
361        builder.set_color_value(color);
362        let hex = color.to_hex_upper(color.a() < 1.0);
363        let name = match &self.label {
364            Some(ls) => ls.resolve_now(),
365            None => {
366                resolve_message_widget("color-picker-swatch-label", &[("hex", hex.clone().into())])
367            }
368        };
369        let display = if self.selected {
370            let suffix = resolve_message_widget("color-picker-swatch-selected-suffix", &[]);
371            format!("{}{}", name, suffix)
372        } else {
373            name
374        };
375        builder.set_name(display);
376        builder.set_value(hex);
377        if self.selected {
378            builder.set_selected(true);
379        }
380        // Framework a11y walker sets `set_disabled` from arena state.
381        builder.add_action(Action::Click);
382        builder.add_action(Action::Focus);
383    }
384
385    fn place_children(
386        &self,
387        _bounds: Rect,
388        _proposal: SizeProposal,
389        _children: &mut [WidgetPlacement],
390        _ctx: &LayoutContext,
391    ) {
392    }
393}