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 subtree from the
24//! AT tree via `.access_exclude_subtree()`. This widget itself emits a
25//! `Role::GenericContainer` placeholder so the override has something
26//! to prune.
27
28use std::cell::Cell;
29use std::rc::Rc;
30
31use teksilo_canvas::paint::GradientStop;
32use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal};
33use teksilo_core::accessibility::AccessNodeBuilder;
34use teksilo_core::accesskit::Role;
35use teksilo_core::build_context::BuildContext;
36use teksilo_core::event::PointerButton;
37use teksilo_core::gesture::DragPhase;
38use teksilo_core::signal::Signal;
39use teksilo_core::widget::{
40    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
41};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{Color, CornerRadius};
45
46pub(crate) struct HsvCanvas {
47    hue: Signal<f32>,
48    saturation: Signal<f32>,
49    value_hsv: Signal<f32>,
50    set_hsv: Rc<dyn Fn(f32, f32, f32)>,
51    dragging: Rc<Cell<bool>>,
52    cached_bounds: Rc<Cell<Rect>>,
53    /// Initial enabled-state; forwarded to the arena at build time.
54    initial_enabled: bool,
55}
56
57impl HsvCanvas {
58    pub(crate) fn new(
59        hue: Signal<f32>,
60        saturation: Signal<f32>,
61        value_hsv: Signal<f32>,
62        set_hsv: Rc<dyn Fn(f32, f32, f32)>,
63        dragging: Rc<Cell<bool>>,
64    ) -> Self {
65        Self {
66            hue,
67            saturation,
68            value_hsv,
69            set_hsv,
70            dragging,
71            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
72            initial_enabled: true,
73        }
74    }
75
76    /// Set the initial enabled state. Forwarded to the arena at build time.
77    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
78        self.initial_enabled = enabled;
79        self
80    }
81}
82
83impl std::fmt::Debug for HsvCanvas {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_struct("HsvCanvas").finish_non_exhaustive()
86    }
87}
88
89impl Widget for HsvCanvas {
90    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
91        let self_id = ctx.self_id();
92        // Forward initial-enabled into the arena; see IconButton.
93        if !self.initial_enabled {
94            ctx.enabled_when(self_id, false);
95        }
96        let registry = ctx.binding_registry();
97        // Bind for repaint when any of the HSV channels move; layout
98        // is fixed, so RepaintOnly is the right level.
99        self.hue.bind_to(
100            self_id,
101            registry,
102            teksilo_core::binding::BindingLevel::RepaintOnly,
103        );
104        self.saturation.bind_to(
105            self_id,
106            registry,
107            teksilo_core::binding::BindingLevel::RepaintOnly,
108        );
109        self.value_hsv.bind_to(
110            self_id,
111            registry,
112            teksilo_core::binding::BindingLevel::RepaintOnly,
113        );
114
115        // Framework gates events on `arena.is_enabled(self_id)`; no
116        // per-handler enabled snapshot.
117        let cached_bounds = self.cached_bounds.clone();
118        let dragging = self.dragging.clone();
119        let set_hsv = self.set_hsv.clone();
120        let hue_for_drag = self.hue.clone();
121
122        let apply: Rc<dyn Fn(f32, f32)> = {
123            let set_hsv = set_hsv.clone();
124            Rc::new(move |x: f32, y: f32| {
125                let bounds = cached_bounds.get();
126                if bounds.width <= 0.0 || bounds.height <= 0.0 {
127                    return;
128                }
129                // `x` / `y` arrive widget-local (origin at the canvas's own
130                // top-left), so no `bounds.x` / `bounds.y` subtraction.
131                let sat = (x / bounds.width).clamp(0.0, 1.0);
132                // Visual convention: top = high value (bright), bottom = low value.
133                let val = (1.0 - y / bounds.height).clamp(0.0, 1.0);
134                (set_hsv)(hue_for_drag.get(), sat, val);
135            })
136        };
137
138        let mut handlers = HandlerSet::new()
139            .focusable(false)
140            .cursor(CursorIcon::Crosshair);
141
142        {
143            let dragging = dragging.clone();
144            let apply = apply.clone();
145            handlers = handlers.on_drag(move |phase, _ctx| match phase {
146                DragPhase::Started {
147                    position,
148                    button: PointerButton::Primary,
149                } => {
150                    dragging.set(true);
151                    apply(position.x, position.y);
152                }
153                DragPhase::Moved { position, .. } if dragging.get() => {
154                    apply(position.x, position.y);
155                }
156                DragPhase::Ended { .. } => {
157                    dragging.set(false);
158                }
159                _ => {}
160            });
161        }
162        {
163            let apply = apply.clone();
164            handlers = handlers.on_tap(move |event, _ctx| {
165                apply(event.position.x, event.position.y);
166            });
167        }
168
169        ctx.apply_self_handlers(handlers);
170        Vec::new()
171    }
172
173    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
174        use crate::styles::recipe_color_picker_style as cp;
175        Size::new(cp::CANVAS_WIDTH, cp::CANVAS_HEIGHT).into()
176    }
177
178    fn place_children(
179        &self,
180        bounds: Rect,
181        _proposal: SizeProposal,
182        _children: &mut [WidgetPlacement],
183        _ctx: &LayoutContext,
184    ) {
185        self.cached_bounds.set(bounds);
186    }
187
188    fn paint(&self, bounds: Rect, canvas: &mut Canvas, _ctx: &PaintContext) {
189        use crate::styles::recipe_color_picker_style as cp;
190        self.cached_bounds.set(bounds);
191        let radius = CornerRadius::uniform(cp::CANVAS_CORNER_RADIUS);
192
193        // Layer 1: solid base — the pure hue at full saturation + value.
194        let hue = self.hue.get();
195        let base = Color::from_hsv(hue, 1.0, 1.0);
196        canvas.fill_rounded_rect(bounds, radius, base);
197
198        // Layer 2: white → transparent (saturation axis, left → right).
199        // `Paint::LinearGradient` endpoints are rect-local (origin at
200        // the rect's top-left, in pixels); see `Paint::LinearGradient`
201        // docs for the rationale.
202        canvas.fill_rounded_rect(
203            bounds,
204            radius,
205            Paint::LinearGradient {
206                start: Point::new(0.0, 0.0),
207                end: Point::new(bounds.width, 0.0),
208                stops: vec![
209                    GradientStop {
210                        offset: 0.0,
211                        color: Color::WHITE,
212                    },
213                    GradientStop {
214                        offset: 1.0,
215                        color: Color::WHITE.with_alpha(0.0),
216                    },
217                ],
218            },
219        );
220
221        // Layer 3: transparent → black (value axis, top → bottom).
222        canvas.fill_rounded_rect(
223            bounds,
224            radius,
225            Paint::LinearGradient {
226                start: Point::new(0.0, 0.0),
227                end: Point::new(0.0, bounds.height),
228                stops: vec![
229                    GradientStop {
230                        offset: 0.0,
231                        color: Color::BLACK.with_alpha(0.0),
232                    },
233                    GradientStop {
234                        offset: 1.0,
235                        color: Color::BLACK,
236                    },
237                ],
238            },
239        );
240
241        // Indicator — double ring (white outer, dark inner) for legibility on any background.
242        let sat = self.saturation.get().clamp(0.0, 1.0);
243        let val = self.value_hsv.get().clamp(0.0, 1.0);
244        let cx = bounds.x + bounds.width * sat;
245        let cy = bounds.y + bounds.height * (1.0 - val);
246        let center = Point::new(cx, cy);
247
248        canvas.stroke_circle(
249            center,
250            cp::INDICATOR_RADIUS + cp::INDICATOR_INNER_STROKE_WIDTH,
251            cp::INDICATOR_OUTER_COLOR,
252            cp::INDICATOR_OUTER_STROKE_WIDTH,
253        );
254        canvas.stroke_circle(
255            center,
256            cp::INDICATOR_RADIUS,
257            cp::INDICATOR_INNER_COLOR,
258            cp::INDICATOR_INNER_STROKE_WIDTH,
259        );
260    }
261
262    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
263        // Placeholder role. The containing ColorPicker excludes this
264        // node's subtree from the AT tree via `.access_exclude_subtree()`,
265        // since 2D pointer gestures have no ARIA equivalent and AT users
266        // rely on the H/S/V/A sliders + RGB/HSV/hex spinners instead.
267        builder.set_role(Role::GenericContainer);
268    }
269}