Skip to main content

teksilo_widgets/color_picker/
hue_strip.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `HueStrip` — 1D hue slider rendered from a CPU-generated 256×1
5//! rainbow texture.
6//!
7//! The SDF gradient pipeline caps each `Paint::LinearGradient` at four
8//! stops. A perceptually smooth full-spectrum hue strip needs at least
9//! seven (red → yellow → green → cyan → blue → magenta → red), so we
10//! generate a 256×1 RGBA texture once via
11//! `Canvas::ensure_image_registered` (idempotent — keyed by name) and
12//! draw it via `draw_image`. The texture costs ~1 KB and is shared
13//! across every `ColorPicker` / `ColorEdit` instance in the process.
14//!
15//! Accessibility is `Role::Slider` with `numeric_value=hue`, range
16//! `0..360`, step 1°, jump 15° (PageUp/Down).
17
18use std::borrow::Cow;
19use std::cell::Cell;
20use std::rc::Rc;
21use std::sync::LazyLock;
22
23use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
24use teksilo_core::accessibility::AccessNodeBuilder;
25use teksilo_core::accesskit::{Action, Role};
26use teksilo_core::build_context::BuildContext;
27use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
28use teksilo_core::focus::FocusOrigin;
29use teksilo_core::gesture::DragPhase;
30use teksilo_core::signal::Signal;
31use teksilo_core::widget::{
32    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
33};
34use teksilo_core::widget_builder::HandlerSet;
35use teksilo_core::widget_id::WidgetId;
36use teksilo_tokens::{Color, CornerRadius, Orientation};
37
38/// One texture per orientation — `draw_image` stretches without
39/// rotation, so a horizontal texture into a vertical strip would
40/// run the gradient across the strip's short axis. Picking by
41/// orientation keeps the rainbow oriented along the strip's long
42/// axis without paint-time rotation.
43const HUE_TEXTURE_NAME_HORIZONTAL: &str = "__teksilo_color_picker_hue_h_256";
44const HUE_TEXTURE_NAME_VERTICAL: &str = "__teksilo_color_picker_hue_v_256";
45const HUE_TEXTURE_LENGTH: u32 = 256;
46
47/// 256-pixel rainbow generated once per orientation. Pixel order in
48/// the vertical buffer matches scan-line order: column 0, rows 0..256
49/// — `draw_image` reads row-major so a single column of 256 rows is a
50/// 1×256 RGBA buffer.
51static HUE_PIXELS: LazyLock<Vec<u8>> = LazyLock::new(generate_hue_pixels);
52
53pub(crate) struct HueStrip {
54    hue: Signal<f32>,
55    set_hue: Rc<dyn Fn(f32)>,
56    dragging: Rc<Cell<bool>>,
57    cached_bounds: Rc<Cell<Rect>>,
58    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
59    orientation: Orientation,
60    /// Initial enabled-state; forwarded to the arena at build time.
61    initial_enabled: bool,
62    label: String,
63}
64
65impl HueStrip {
66    pub(crate) fn new(
67        hue: Signal<f32>,
68        set_hue: Rc<dyn Fn(f32)>,
69        dragging: Rc<Cell<bool>>,
70    ) -> Self {
71        Self {
72            hue,
73            set_hue,
74            dragging,
75            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
76            focus_origin: Rc::new(Cell::new(None)),
77            orientation: Orientation::Vertical,
78            initial_enabled: true,
79            label: String::new(),
80        }
81    }
82
83    pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
84        self.orientation = orientation;
85        self
86    }
87
88    /// Set the initial enabled state. Forwarded to the arena at build time.
89    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
90        self.initial_enabled = enabled;
91        self
92    }
93
94    pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
95        self.label = label.into();
96        self
97    }
98}
99
100impl std::fmt::Debug for HueStrip {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("HueStrip")
103            .field("orientation", &self.orientation)
104            .finish_non_exhaustive()
105    }
106}
107
108impl Widget for HueStrip {
109    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
110        let self_id = ctx.self_id();
111        // Forward initial-enabled into the arena; see IconButton.
112        if !self.initial_enabled {
113            ctx.enabled_when(self_id, false);
114        }
115        let registry = ctx.binding_registry();
116        self.hue.bind_to(
117            self_id,
118            registry,
119            teksilo_core::binding::BindingLevel::RepaintOnly,
120        );
121
122        // Framework gates events on `arena.is_enabled(self_id)`.
123        let cached_bounds = self.cached_bounds.clone();
124        let dragging = self.dragging.clone();
125        let set_hue = self.set_hue.clone();
126        let orientation = self.orientation;
127
128        let apply: Rc<dyn Fn(f32, f32)> = {
129            let set_hue = set_hue.clone();
130            Rc::new(move |x: f32, y: f32| {
131                let bounds = cached_bounds.get();
132                // `x` / `y` arrive widget-local (origin at the strip's own
133                // top-left), so no `bounds.x` / `bounds.y` subtraction.
134                let t = match orientation {
135                    Orientation::Vertical => {
136                        if bounds.height <= 0.0 {
137                            return;
138                        }
139                        (y / bounds.height).clamp(0.0, 1.0)
140                    }
141                    Orientation::Horizontal => {
142                        if bounds.width <= 0.0 {
143                            return;
144                        }
145                        (x / bounds.width).clamp(0.0, 1.0)
146                    }
147                };
148                // Map 0..1 to 0..360 — clamp to 359.999 so wrap doesn't flip
149                // an end-of-strip click back to red-at-the-start.
150                let h = (t * 360.0).min(359.999);
151                (set_hue)(h);
152            })
153        };
154
155        let mut handlers = HandlerSet::new()
156            .focusable(true)
157            .cursor(CursorIcon::Pointer);
158
159        {
160            let dragging = dragging.clone();
161            let apply = apply.clone();
162            handlers = handlers.on_drag(move |phase, _ctx| match phase {
163                DragPhase::Started {
164                    position,
165                    button: PointerButton::Primary,
166                } => {
167                    dragging.set(true);
168                    apply(position.x, position.y);
169                }
170                DragPhase::Moved { position, .. } if dragging.get() => {
171                    apply(position.x, position.y);
172                }
173                DragPhase::Ended { .. } => {
174                    dragging.set(false);
175                }
176                _ => {}
177            });
178        }
179        {
180            let apply = apply.clone();
181            handlers = handlers.on_tap(move |event, _ctx| {
182                apply(event.position.x, event.position.y);
183            });
184        }
185
186        // Keyboard.
187        {
188            let set_hue = set_hue.clone();
189            let hue = self.hue.clone();
190            handlers = handlers.on_key(move |event, _ctx| {
191                let WidgetEvent::KeyDown { key, .. } = event else {
192                    return EventResponse::Ignored;
193                };
194                match key {
195                    Key::ArrowUp | Key::ArrowRight => {
196                        let next = (hue.get() + 1.0).rem_euclid(360.0);
197                        (set_hue)(next);
198                        EventResponse::Handled
199                    }
200                    Key::ArrowDown | Key::ArrowLeft => {
201                        let next = (hue.get() - 1.0).rem_euclid(360.0);
202                        (set_hue)(next);
203                        EventResponse::Handled
204                    }
205                    Key::PageUp => {
206                        let next = (hue.get() + 15.0).rem_euclid(360.0);
207                        (set_hue)(next);
208                        EventResponse::Handled
209                    }
210                    Key::PageDown => {
211                        let next = (hue.get() - 15.0).rem_euclid(360.0);
212                        (set_hue)(next);
213                        EventResponse::Handled
214                    }
215                    Key::Home => {
216                        (set_hue)(0.0);
217                        EventResponse::Handled
218                    }
219                    Key::End => {
220                        (set_hue)(359.0);
221                        EventResponse::Handled
222                    }
223                    _ => EventResponse::Ignored,
224                }
225            });
226        }
227
228        {
229            let focus_origin = self.focus_origin.clone();
230            handlers = handlers.on_focus(move |gained, _ctx| {
231                focus_origin.set(if gained {
232                    Some(FocusOrigin::Keyboard)
233                } else {
234                    None
235                });
236            });
237        }
238
239        // AccessKit Increment / Decrement.
240        {
241            let set_hue = set_hue.clone();
242            let hue = self.hue.clone();
243            handlers = handlers.on_access_action(move |action, _ctx| match action {
244                Action::Increment => {
245                    (set_hue)((hue.get() + 1.0).rem_euclid(360.0));
246                    EventResponse::Handled
247                }
248                Action::Decrement => {
249                    (set_hue)((hue.get() - 1.0).rem_euclid(360.0));
250                    EventResponse::Handled
251                }
252                _ => EventResponse::Ignored,
253            });
254        }
255
256        ctx.apply_self_handlers(handlers);
257        Vec::new()
258    }
259
260    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
261        use crate::styles::recipe_color_picker_style as cp;
262        let size = match self.orientation {
263            Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
264            Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
265        };
266        size.into()
267    }
268
269    fn place_children(
270        &self,
271        bounds: Rect,
272        _proposal: SizeProposal,
273        _children: &mut [WidgetPlacement],
274        _ctx: &LayoutContext,
275    ) {
276        self.cached_bounds.set(bounds);
277    }
278
279    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
280        use crate::styles::recipe_color_picker_style as cp;
281        self.cached_bounds.set(bounds);
282        let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
283
284        // Register the rainbow texture for the strip's orientation.
285        // Same pixel data either way (256 RGBA quartets in hue order);
286        // dimensions decide whether scan-line order maps onto the
287        // strip's long axis horizontally or vertically.
288        let (texture_name, tex_w, tex_h) = match self.orientation {
289            Orientation::Horizontal => (HUE_TEXTURE_NAME_HORIZONTAL, HUE_TEXTURE_LENGTH, 1),
290            Orientation::Vertical => (HUE_TEXTURE_NAME_VERTICAL, 1, HUE_TEXTURE_LENGTH),
291        };
292        canvas.ensure_image_registered(
293            texture_name,
294            tex_w,
295            tex_h,
296            Cow::Borrowed(HUE_PIXELS.as_slice()),
297        );
298
299        // Background frame (under the rainbow) — protects against
300        // rounded-rect corner anti-aliasing leaving the picker surface
301        // visible at the strip's rounded corners. Drawn first.
302        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
303        canvas.draw_image(bounds, texture_name);
304
305        // Border frame.
306        canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
307
308        // Thumb at the current hue position.
309        let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
310        let thumb_w = cp::STRIP_THUMB_WIDTH;
311        let thumb_h = cp::STRIP_THUMB_HEIGHT;
312        let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
313        let (cx, cy) = match self.orientation {
314            Orientation::Vertical => (bounds.x + bounds.width * 0.5, bounds.y + bounds.height * t),
315            Orientation::Horizontal => {
316                (bounds.x + bounds.width * t, bounds.y + bounds.height * 0.5)
317            }
318        };
319        let thumb_rect = match self.orientation {
320            Orientation::Vertical => Rect::new(
321                bounds.x - 2.0,
322                cy - thumb_h * 0.5,
323                bounds.width + 4.0,
324                thumb_h,
325            ),
326            Orientation::Horizontal => Rect::new(
327                cx - thumb_w * 0.5,
328                bounds.y - 2.0,
329                thumb_w,
330                bounds.height + 4.0,
331            ),
332        };
333        canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
334        canvas.stroke_rounded_rect(
335            thumb_rect,
336            thumb_radius,
337            Color::new(0.0, 0.0, 0.0, 0.5),
338            1.0,
339        );
340    }
341
342    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
343        builder.set_role(Role::Slider);
344        if !self.label.is_empty() {
345            builder.set_name(&self.label);
346        }
347        builder.set_numeric_value(self.hue.get() as f64);
348        builder.set_min_numeric_value(0.0);
349        builder.set_max_numeric_value(360.0);
350        builder.set_numeric_value_step(1.0);
351        builder.set_numeric_value_jump(15.0);
352        let orientation = match self.orientation {
353            Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
354            Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
355        };
356        builder.set_orientation(orientation);
357        // Framework a11y walker sets `set_disabled` from arena state.
358        builder.add_action(Action::Increment);
359        builder.add_action(Action::Decrement);
360        builder.add_action(Action::Focus);
361    }
362}
363
364/// Generate a 256×1 RGBA rainbow texture. Each pixel `i` is the color
365/// `Color::from_hsv(i/256·360, 1, 1)` packed as four `u8`s.
366fn generate_hue_pixels() -> Vec<u8> {
367    let mut pixels = Vec::with_capacity(HUE_TEXTURE_LENGTH as usize * 4);
368    for i in 0..HUE_TEXTURE_LENGTH {
369        let h = (i as f32 / HUE_TEXTURE_LENGTH as f32) * 360.0;
370        let c = Color::from_hsv(h, 1.0, 1.0);
371        pixels.push((c.r() * 255.0) as u8);
372        pixels.push((c.g() * 255.0) as u8);
373        pixels.push((c.b() * 255.0) as u8);
374        pixels.push(255);
375    }
376    pixels
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn hue_pixels_are_rainbow() {
385        let pixels = generate_hue_pixels();
386        assert_eq!(pixels.len(), HUE_TEXTURE_LENGTH as usize * 4);
387        // First pixel is red.
388        assert_eq!(pixels[0], 255);
389        assert_eq!(pixels[1], 0);
390        assert_eq!(pixels[2], 0);
391        // Middle pixel ≈ cyan.
392        let mid = (HUE_TEXTURE_LENGTH as usize / 2) * 4;
393        assert!(pixels[mid] < 50);
394        assert!(pixels[mid + 1] > 200);
395        assert!(pixels[mid + 2] > 200);
396    }
397}