Skip to main content

teksilo_widgets/color_picker/
hsv_canvas.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `HsvCanvas` — 2D saturation × value picker rendered as three stacked
5//! gradients.
6//!
7//! Layer 1: solid fill with the current hue at full saturation + full
8//! value. Layer 2: white→transparent linear gradient left→right
9//! (saturation axis). Layer 3: transparent→black linear gradient
10//! top→bottom (value axis, inverted so the top of the canvas is the
11//! bright end). The three layers composite under standard SrcOver
12//! blending to form the HSV picker's familiar square gradient.
13//!
14//! The current selection is shown as a double-ring indicator (white
15//! outer, dark inner) so it stays visible against any underlying
16//! color combination.
17//!
18//! # Accessibility
19//!
20//! The canvas is fundamentally a 2D pointer gesture with no ARIA
21//! precedent. Screen-reader users navigate the picker via the hue /
22//! saturation / value sliders or RGB / HSV / hex spinners — the
23//! containing `ColorPicker` excludes this widget's descendants from the
24//! AT tree via `.access_subtree(AccessSubtreeMode::Exclude)` (it has
25//! none). The widget's own node still emits normally: a named
26//! `Role::Group` carrying the saturation/brightness value as text plus
27//! four custom actions (one per `CanvasStep`), so the control stays
28//! reachable and drivable by assistive technology rather than being a
29//! placeholder the walker prunes.
30
31use std::cell::Cell;
32use std::rc::Rc;
33
34use teksilo_canvas::paint::GradientStop;
35use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::accesskit::Role;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
40use teksilo_core::gesture::DragPhase;
41use teksilo_core::pointer::touch_action::TouchAction;
42use teksilo_core::signal::Signal;
43use teksilo_core::widget::{
44    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
45};
46use teksilo_core::widget_builder::HandlerSet;
47use teksilo_core::widget_id::WidgetId;
48use teksilo_i18n::{LocalizedString, lit};
49use teksilo_tokens::{Color, CornerRadius};
50
51/// One step of the 2-D value, in the four directions the arrows name.
52///
53/// A saturation-and-brightness field is the one control in the picker whose
54/// value is a *pair*, so `Action::Increment` cannot serve it: an assistive
55/// client asking to "increment" would not be saying which axis. Four named
56/// custom actions do say.
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58enum CanvasStep {
59    SaturationUp,
60    SaturationDown,
61    BrightnessUp,
62    BrightnessDown,
63}
64
65/// One arrow press, as a fraction of the axis.
66const FINE_STEP: f32 = 0.01;
67
68/// One arrow press with `Shift` held.
69const COARSE_STEP: f32 = 0.10;
70
71impl CanvasStep {
72    const ALL: [CanvasStep; 4] = [
73        Self::SaturationDown,
74        Self::SaturationUp,
75        Self::BrightnessDown,
76        Self::BrightnessUp,
77    ];
78
79    /// The arrow that means this step.
80    ///
81    /// Deliberately **not** mirrored under RTL, unlike `Slider`: the canvas
82    /// paints its saturation gradient left-to-right in every direction, so an
83    /// arrow that mirrored would disagree with the picture the user is looking
84    /// at. A slider mirrors because its paint does.
85    fn from_key(key: Key) -> Option<Self> {
86        match key {
87            Key::ArrowRight => Some(Self::SaturationUp),
88            Key::ArrowLeft => Some(Self::SaturationDown),
89            Key::ArrowUp => Some(Self::BrightnessUp),
90            Key::ArrowDown => Some(Self::BrightnessDown),
91            _ => None,
92        }
93    }
94
95    fn label(self) -> LocalizedString {
96        match self {
97            Self::SaturationUp => lit!("Increase saturation"),
98            Self::SaturationDown => lit!("Decrease saturation"),
99            Self::BrightnessUp => lit!("Increase brightness"),
100            Self::BrightnessDown => lit!("Decrease brightness"),
101        }
102    }
103}
104
105pub(crate) struct HsvCanvas {
106    hue: Signal<f32>,
107    saturation: Signal<f32>,
108    value_hsv: Signal<f32>,
109    set_hsv: Rc<dyn Fn(f32, f32, f32)>,
110    dragging: Rc<Cell<bool>>,
111    cached_bounds: Rc<Cell<Rect>>,
112    /// Initial enabled-state; forwarded to the arena at build time.
113    initial_enabled: bool,
114}
115
116impl HsvCanvas {
117    pub(crate) fn new(
118        hue: Signal<f32>,
119        saturation: Signal<f32>,
120        value_hsv: Signal<f32>,
121        set_hsv: Rc<dyn Fn(f32, f32, f32)>,
122        dragging: Rc<Cell<bool>>,
123    ) -> Self {
124        Self {
125            hue,
126            saturation,
127            value_hsv,
128            set_hsv,
129            dragging,
130            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
131            initial_enabled: true,
132        }
133    }
134
135    /// Set the initial enabled state. Forwarded to the arena at build time.
136    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
137        self.initial_enabled = enabled;
138        self
139    }
140}
141
142impl std::fmt::Debug for HsvCanvas {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("HsvCanvas").finish_non_exhaustive()
145    }
146}
147
148impl Widget for HsvCanvas {
149    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
150        let self_id = ctx.self_id();
151        // Forward initial-enabled into the arena; see IconButton.
152        if !self.initial_enabled {
153            ctx.enabled_when(self_id, false);
154        }
155        let registry = ctx.binding_registry();
156        // Bind for repaint when any of the HSV channels move; layout
157        // is fixed, so RepaintOnly is the right level.
158        self.hue.bind_to(
159            self_id,
160            registry,
161            teksilo_core::binding::BindingLevel::RepaintOnly,
162        );
163        self.saturation.bind_to(
164            self_id,
165            registry,
166            teksilo_core::binding::BindingLevel::RepaintOnly,
167        );
168        self.value_hsv.bind_to(
169            self_id,
170            registry,
171            teksilo_core::binding::BindingLevel::RepaintOnly,
172        );
173
174        // Framework gates events on `arena.is_enabled(self_id)`; no
175        // per-handler enabled snapshot.
176        let cached_bounds = self.cached_bounds.clone();
177        let dragging = self.dragging.clone();
178        let set_hsv = self.set_hsv.clone();
179        let hue_for_drag = self.hue.clone();
180
181        let apply: Rc<dyn Fn(f32, f32)> = {
182            let set_hsv = set_hsv.clone();
183            Rc::new(move |x: f32, y: f32| {
184                let bounds = cached_bounds.get();
185                if bounds.width <= 0.0 || bounds.height <= 0.0 {
186                    return;
187                }
188                // `x` / `y` arrive widget-local (origin at the canvas's own
189                // top-left), so no `bounds.x` / `bounds.y` subtraction.
190                let sat = (x / bounds.width).clamp(0.0, 1.0);
191                // Visual convention: top = high value (bright), bottom = low value.
192                let val = (1.0 - y / bounds.height).clamp(0.0, 1.0);
193                (set_hsv)(hue_for_drag.get(), sat, val);
194            })
195        };
196
197        let mut handlers = HandlerSet::new()
198            // Focusable, because the 2-D drag is the only way to reach a
199            // saturation-and-brightness pair on this control and WCAG 2.2
200            // SC 2.5.7 wants one that is not a drag. The arrows below are that
201            // route; the numeric entry beside the canvas is the other.
202            .focusable(true)
203            .cursor(CursorIcon::Crosshair)
204            // A continuous manipulator: the value it produces IS the press
205            // position, so no default touch behaviour may be run on it. `NONE`
206            // freezes the hit path's touch action at the press
207            // (`docs/touch-and-pen.md` §7.3); what it forbids, with a test on
208            // it, is a two-contact pinch started on this control reaching the
209            // surface underneath. The press capture the drag takes is what
210            // separately keeps a scroller the picker sits in from taking the
211            // gesture away.
212            .touch_action(TouchAction::NONE);
213
214        // The non-drag route: the arrows step saturation and brightness, Shift
215        // steps by ten times as much, and the same four steps are advertised as
216        // AccessKit custom actions for a client with no keyboard either. All of
217        // them go through one closure, so the routes cannot disagree, and each
218        // one announces the pair it produced — a screen-reader user pressing an
219        // arrow on a colour field otherwise hears nothing at all.
220        let step_hsv: Rc<dyn Fn(CanvasStep, bool, &mut teksilo_core::widget::EventContext)> = {
221            let set_hsv = set_hsv.clone();
222            let hue = self.hue.clone();
223            let saturation = self.saturation.clone();
224            let value_hsv = self.value_hsv.clone();
225            Rc::new(move |step, coarse, ctx| {
226                let delta = if coarse { COARSE_STEP } else { FINE_STEP };
227                let (mut sat, mut val) = (
228                    saturation.get().clamp(0.0, 1.0),
229                    value_hsv.get().clamp(0.0, 1.0),
230                );
231                match step {
232                    CanvasStep::SaturationUp => sat = (sat + delta).min(1.0),
233                    CanvasStep::SaturationDown => sat = (sat - delta).max(0.0),
234                    CanvasStep::BrightnessUp => val = (val + delta).min(1.0),
235                    CanvasStep::BrightnessDown => val = (val - delta).max(0.0),
236                }
237                (set_hsv)(hue.get(), sat, val);
238                ctx.announce(format!(
239                    "Saturation {}%, brightness {}%",
240                    (sat * 100.0).round() as i32,
241                    (val * 100.0).round() as i32
242                ));
243            })
244        };
245
246        {
247            let step_hsv = step_hsv.clone();
248            handlers = handlers.on_key(move |event, ctx| {
249                if let WidgetEvent::KeyDown { key, modifiers, .. } = event
250                    && let Some(step) = CanvasStep::from_key(*key)
251                {
252                    step_hsv(step, modifiers.shift(), ctx);
253                    return EventResponse::Handled;
254                }
255                EventResponse::Ignored
256            });
257        }
258        for step in CanvasStep::ALL {
259            let step_hsv = step_hsv.clone();
260            handlers =
261                handlers.access_custom_action(step.label(), move |ctx| step_hsv(step, false, ctx));
262        }
263
264        {
265            let dragging = dragging.clone();
266            let apply = apply.clone();
267            handlers = handlers.on_drag(move |phase, _ctx| match phase {
268                DragPhase::Started {
269                    position,
270                    button: PointerButton::Primary,
271                    ..
272                } => {
273                    dragging.set(true);
274                    apply(position.x, position.y);
275                }
276                DragPhase::Moved { position, .. } if dragging.get() => {
277                    apply(position.x, position.y);
278                }
279                DragPhase::Ended { .. } => {
280                    dragging.set(false);
281                }
282                _ => {}
283            });
284        }
285        {
286            let apply = apply.clone();
287            handlers = handlers.on_tap(move |event, _ctx| {
288                apply(event.position.x, event.position.y);
289            });
290        }
291
292        ctx.apply_self_handlers(handlers);
293        Vec::new()
294    }
295
296    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
297        use crate::styles::recipe_color_picker_style as cp;
298        Size::new(cp::CANVAS_WIDTH, cp::CANVAS_HEIGHT).into()
299    }
300
301    fn place_children(
302        &self,
303        bounds: Rect,
304        _proposal: SizeProposal,
305        _children: &mut [WidgetPlacement],
306        _ctx: &LayoutContext,
307    ) {
308        self.cached_bounds.set(bounds);
309    }
310
311    fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
312        use crate::styles::recipe_color_picker_style as cp;
313        self.cached_bounds.set(bounds);
314        let radius = CornerRadius::uniform(cp::CANVAS_CORNER_RADIUS);
315
316        // Layer 1: solid base — the pure hue at full saturation + value.
317        let hue = self.hue.get();
318        let base = Color::from_hsv(hue, 1.0, 1.0);
319        canvas.fill_rounded_rect(bounds, radius, base);
320
321        // Layer 2: white → transparent (saturation axis, left → right).
322        // `Paint::LinearGradient` endpoints are rect-local (origin at
323        // the rect's top-left, in pixels); see `Paint::LinearGradient`
324        // docs for the rationale.
325        canvas.fill_rounded_rect(
326            bounds,
327            radius,
328            Paint::LinearGradient {
329                start: Point::new(0.0, 0.0),
330                end: Point::new(bounds.width, 0.0),
331                stops: vec![
332                    GradientStop {
333                        offset: 0.0,
334                        color: Color::WHITE,
335                    },
336                    GradientStop {
337                        offset: 1.0,
338                        color: Color::WHITE.with_alpha(0.0),
339                    },
340                ],
341            },
342        );
343
344        // Layer 3: transparent → black (value axis, top → bottom).
345        canvas.fill_rounded_rect(
346            bounds,
347            radius,
348            Paint::LinearGradient {
349                start: Point::new(0.0, 0.0),
350                end: Point::new(0.0, bounds.height),
351                stops: vec![
352                    GradientStop {
353                        offset: 0.0,
354                        color: Color::BLACK.with_alpha(0.0),
355                    },
356                    GradientStop {
357                        offset: 1.0,
358                        color: Color::BLACK,
359                    },
360                ],
361            },
362        );
363
364        // Indicator — double ring (white outer, dark inner) for legibility on any background.
365        let sat = self.saturation.get().clamp(0.0, 1.0);
366        let val = self.value_hsv.get().clamp(0.0, 1.0);
367        let cx = bounds.x + bounds.width * sat;
368        let cy = bounds.y + bounds.height * (1.0 - val);
369        let center = Point::new(cx, cy);
370
371        canvas.stroke_circle(
372            center,
373            cp::INDICATOR_RADIUS + cp::INDICATOR_INNER_STROKE_WIDTH,
374            cp::INDICATOR_OUTER_COLOR,
375            cp::INDICATOR_OUTER_STROKE_WIDTH,
376        );
377        canvas.stroke_circle(
378            center,
379            cp::INDICATOR_RADIUS,
380            cp::INDICATOR_INNER_COLOR,
381            cp::INDICATOR_INNER_STROKE_WIDTH,
382        );
383    }
384
385    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
386        // A 2-D value has no ARIA role — there is no `slider2d` — so the node is
387        // a named group carrying the four steps as custom actions. It is NOT a
388        // `GenericContainer` with no properties: the walker prunes those, and a
389        // pruned node advertises nothing, which is what left this control
390        // undriveable by assistive technology.
391        //
392        // The value is on the node as text rather than as a number, because
393        // `numeric_value` holds one number and this control has two.
394        builder.set_role(Role::Group);
395        builder.set_name(lit!("Saturation and brightness").resolve_now());
396        builder.set_value(format!(
397            "Saturation {}%, brightness {}%",
398            (self.saturation.get().clamp(0.0, 1.0) * 100.0).round() as i32,
399            (self.value_hsv.get().clamp(0.0, 1.0) * 100.0).round() as i32
400        ));
401    }
402}