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, PointerButton, WidgetEvent};
28use teksilo_core::focus::FocusOrigin;
29use teksilo_core::gesture::DragPhase;
30use teksilo_core::pointer::touch_action::TouchAction;
31use teksilo_core::signal::Signal;
32use teksilo_core::widget::{
33    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
34};
35use teksilo_core::widget_builder::HandlerSet;
36use teksilo_core::widget_id::WidgetId;
37use teksilo_tokens::{Color, CornerRadius, Orientation};
38
39use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
40
41/// One texture per orientation — `draw_image` stretches without
42/// rotation, so a horizontal texture into a vertical strip would
43/// run the gradient across the strip's short axis. Picking by
44/// orientation keeps the rainbow oriented along the strip's long
45/// axis without paint-time rotation.
46const HUE_TEXTURE_NAME_HORIZONTAL: &str = "__teksilo_color_picker_hue_h_256";
47const HUE_TEXTURE_NAME_VERTICAL: &str = "__teksilo_color_picker_hue_v_256";
48const HUE_TEXTURE_LENGTH: u32 = 256;
49
50/// 256-pixel rainbow generated once per orientation. Pixel order in
51/// the vertical buffer matches scan-line order: column 0, rows 0..256
52/// — `draw_image` reads row-major so a single column of 256 rows is a
53/// 1×256 RGBA buffer.
54static HUE_PIXELS: LazyLock<Vec<u8>> = LazyLock::new(generate_hue_pixels);
55
56/// The same rainbow read bottom-up, for the vertical strip.
57///
58/// A vertical bounded scalar's maximum sits at the **top** in Teksilo, as it
59/// does in Qt, GTK4, the Win32 trackbar and `<input type=range>` — that is what
60/// makes `ArrowUp` raise the thumb. Scan-line order runs downward, so the
61/// vertical texture has to carry the hues in reverse or the strip would paint
62/// its maximum where its minimum now is.
63static HUE_PIXELS_VERTICAL: LazyLock<Vec<u8>> = LazyLock::new(|| {
64    HUE_PIXELS
65        .as_chunks::<4>()
66        .0
67        .iter()
68        .rev()
69        .flat_map(|px| px.iter().copied())
70        .collect()
71});
72
73pub(crate) struct HueStrip {
74    hue: Signal<f32>,
75    set_hue: Rc<dyn Fn(f32)>,
76    dragging: Rc<Cell<bool>>,
77    cached_bounds: Rc<Cell<Rect>>,
78    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
79    orientation: Orientation,
80    /// Initial enabled-state; forwarded to the arena at build time.
81    initial_enabled: bool,
82    label: String,
83}
84
85impl HueStrip {
86    pub(crate) fn new(
87        hue: Signal<f32>,
88        set_hue: Rc<dyn Fn(f32)>,
89        dragging: Rc<Cell<bool>>,
90    ) -> Self {
91        Self {
92            hue,
93            set_hue,
94            dragging,
95            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
96            focus_origin: Rc::new(Cell::new(None)),
97            orientation: Orientation::Vertical,
98            initial_enabled: true,
99            label: String::new(),
100        }
101    }
102
103    pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
104        self.orientation = orientation;
105        self
106    }
107
108    /// Set the initial enabled state. Forwarded to the arena at build time.
109    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
110        self.initial_enabled = enabled;
111        self
112    }
113
114    pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
115        self.label = label.into();
116        self
117    }
118}
119
120impl std::fmt::Debug for HueStrip {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("HueStrip")
123            .field("orientation", &self.orientation)
124            .finish_non_exhaustive()
125    }
126}
127
128impl Widget for HueStrip {
129    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
130        let self_id = ctx.self_id();
131        // Forward initial-enabled into the arena; see IconButton.
132        if !self.initial_enabled {
133            ctx.enabled_when(self_id, false);
134        }
135        let registry = ctx.binding_registry();
136        self.hue.bind_to(
137            self_id,
138            registry,
139            teksilo_core::binding::BindingLevel::RepaintOnly,
140        );
141
142        // Framework gates events on `arena.is_enabled(self_id)`.
143        let cached_bounds = self.cached_bounds.clone();
144        let dragging = self.dragging.clone();
145        let set_hue = self.set_hue.clone();
146        let orientation = self.orientation;
147
148        let apply: Rc<dyn Fn(f32, f32)> = {
149            let set_hue = set_hue.clone();
150            Rc::new(move |x: f32, y: f32| {
151                let bounds = cached_bounds.get();
152                // `x` / `y` arrive widget-local (origin at the strip's own
153                // top-left), so no `bounds.x` / `bounds.y` subtraction.
154                let t = match orientation {
155                    // `y` grows downward and the maximum is at the top, so the
156                    // pointer's ratio is the value's complement — the same
157                    // mirror `Slider` applies on its vertical axis, and what
158                    // keeps `ArrowUp` and a drag upward moving the same way.
159                    Orientation::Vertical => {
160                        if bounds.height <= 0.0 {
161                            return;
162                        }
163                        1.0 - (y / bounds.height).clamp(0.0, 1.0)
164                    }
165                    Orientation::Horizontal => {
166                        if bounds.width <= 0.0 {
167                            return;
168                        }
169                        (x / bounds.width).clamp(0.0, 1.0)
170                    }
171                };
172                // Map 0..1 to 0..360 — clamp to 359.999 so wrap doesn't flip
173                // an end-of-strip click back to red-at-the-start.
174                let h = (t * 360.0).min(359.999);
175                (set_hue)(h);
176            })
177        };
178
179        let mut handlers = HandlerSet::new()
180            .focusable(true)
181            .cursor(CursorIcon::Pointer)
182            // A continuous manipulator: the value it produces IS the press
183            // position, so no default touch behaviour may be run on it. `NONE`
184            // freezes the hit path's touch action at the press
185            // (`docs/touch-and-pen.md` §7.3); what it forbids, with a test on
186            // it, is a two-contact pinch started on this control reaching the
187            // surface underneath. The press capture the drag takes is what
188            // separately keeps a scroller the picker sits in from taking the
189            // gesture away.
190            .touch_action(TouchAction::NONE);
191
192        {
193            let dragging = dragging.clone();
194            let apply = apply.clone();
195            handlers = handlers.on_drag(move |phase, _ctx| match phase {
196                DragPhase::Started {
197                    position,
198                    button: PointerButton::Primary,
199                    ..
200                } => {
201                    dragging.set(true);
202                    apply(position.x, position.y);
203                }
204                DragPhase::Moved { position, .. } if dragging.get() => {
205                    apply(position.x, position.y);
206                }
207                DragPhase::Ended { .. } => {
208                    dragging.set(false);
209                }
210                _ => {}
211            });
212        }
213        {
214            let apply = apply.clone();
215            handlers = handlers.on_tap(move |event, _ctx| {
216                apply(event.position.x, event.position.y);
217            });
218        }
219
220        // Keyboard.
221        {
222            let set_hue = set_hue.clone();
223            let hue = self.hue.clone();
224            handlers = handlers.on_key(move |event, _ctx| {
225                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
226                    return EventResponse::Ignored;
227                };
228                // `rtl` is false: the rainbow texture and the pointer mapping
229                // both run leading-to-trailing unconditionally, so the arrows
230                // must not mirror on their own.
231                let Some(mv) = range_nav::range_move(
232                    *key,
233                    *modifiers,
234                    RangeKind::Scalar,
235                    RangeAxis::Both,
236                    false,
237                ) else {
238                    return EventResponse::Ignored;
239                };
240                // Hue is a circle cut open: a step past either edge lands on
241                // the other side, but `Home` / `End` still name the two ends of
242                // the cut. The magnitudes stay in agreement with the
243                // `numeric_value_step` / `numeric_value_jump` published below.
244                let next = match mv {
245                    RangeMove::Step { increase } => {
246                        (hue.get() + if increase { 1.0 } else { -1.0 }).rem_euclid(360.0)
247                    }
248                    RangeMove::Page { increase } => {
249                        (hue.get() + if increase { 15.0 } else { -15.0 }).rem_euclid(360.0)
250                    }
251                    RangeMove::ToMin => 0.0,
252                    RangeMove::ToMax => 359.0,
253                };
254                (set_hue)(next);
255                EventResponse::Handled
256            });
257        }
258
259        {
260            let focus_origin = self.focus_origin.clone();
261            handlers = handlers.on_focus(move |gained, _ctx| {
262                focus_origin.set(if gained {
263                    Some(FocusOrigin::Keyboard)
264                } else {
265                    None
266                });
267            });
268        }
269
270        // AccessKit Increment / Decrement.
271        {
272            let set_hue = set_hue.clone();
273            let hue = self.hue.clone();
274            handlers = handlers.on_access_action(move |action, _ctx| match action {
275                Action::Increment => {
276                    (set_hue)((hue.get() + 1.0).rem_euclid(360.0));
277                    EventResponse::Handled
278                }
279                Action::Decrement => {
280                    (set_hue)((hue.get() - 1.0).rem_euclid(360.0));
281                    EventResponse::Handled
282                }
283                _ => EventResponse::Ignored,
284            });
285        }
286
287        ctx.apply_self_handlers(handlers);
288        Vec::new()
289    }
290
291    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
292        use crate::styles::recipe_color_picker_style as cp;
293        let size = match self.orientation {
294            Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
295            Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
296        };
297        size.into()
298    }
299
300    fn place_children(
301        &self,
302        bounds: Rect,
303        _proposal: SizeProposal,
304        _children: &mut [WidgetPlacement],
305        _ctx: &LayoutContext,
306    ) {
307        self.cached_bounds.set(bounds);
308    }
309
310    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
311        use crate::styles::recipe_color_picker_style as cp;
312        self.cached_bounds.set(bounds);
313        let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
314
315        // Register the rainbow texture for the strip's orientation. The
316        // dimensions decide whether scan-line order maps onto the strip's long
317        // axis horizontally or vertically; the vertical buffer is the reversed
318        // one, because that strip's maximum is at the top.
319        let (texture_name, tex_w, tex_h, pixels) = match self.orientation {
320            Orientation::Horizontal => (
321                HUE_TEXTURE_NAME_HORIZONTAL,
322                HUE_TEXTURE_LENGTH,
323                1,
324                HUE_PIXELS.as_slice(),
325            ),
326            Orientation::Vertical => (
327                HUE_TEXTURE_NAME_VERTICAL,
328                1,
329                HUE_TEXTURE_LENGTH,
330                HUE_PIXELS_VERTICAL.as_slice(),
331            ),
332        };
333        canvas.ensure_image_registered(texture_name, tex_w, tex_h, Cow::Borrowed(pixels));
334
335        // Background frame (under the rainbow) — protects against
336        // rounded-rect corner anti-aliasing leaving the picker surface
337        // visible at the strip's rounded corners. Drawn first.
338        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
339        canvas.draw_image(bounds, texture_name);
340
341        // Border frame.
342        canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
343
344        // Thumb at the current hue position.
345        let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
346        let thumb_w = cp::STRIP_THUMB_WIDTH;
347        let thumb_h = cp::STRIP_THUMB_HEIGHT;
348        let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
349        let (cx, cy) = match self.orientation {
350            // Maximum at the top, so the thumb travels up as the hue grows.
351            Orientation::Vertical => (
352                bounds.x + bounds.width * 0.5,
353                bounds.bottom() - bounds.height * t,
354            ),
355            Orientation::Horizontal => {
356                (bounds.x + bounds.width * t, bounds.y + bounds.height * 0.5)
357            }
358        };
359        let thumb_rect = match self.orientation {
360            Orientation::Vertical => Rect::new(
361                bounds.x - 2.0,
362                cy - thumb_h * 0.5,
363                bounds.width + 4.0,
364                thumb_h,
365            ),
366            Orientation::Horizontal => Rect::new(
367                cx - thumb_w * 0.5,
368                bounds.y - 2.0,
369                thumb_w,
370                bounds.height + 4.0,
371            ),
372        };
373        canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
374        canvas.stroke_rounded_rect(
375            thumb_rect,
376            thumb_radius,
377            Color::new(0.0, 0.0, 0.0, 0.5),
378            1.0,
379        );
380    }
381
382    /// A 14 dp strip is well under the 24 dp conformance floor across its short
383    /// axis, and it cannot grow: the picker's column geometry is built around
384    /// it, so widening the paint at Compact would move the whole panel. The
385    /// shortfall is made up between the pointer and the arena instead. The long
386    /// axis is already generous and takes nothing, which also keeps the two
387    /// strips from reaching into the controls above and below them.
388    fn hit_outset(
389        &self,
390        kind: teksilo_tokens::PointerKind,
391        tokens: &teksilo_tokens::InputTokens,
392    ) -> teksilo_canvas::EdgeInsets {
393        crate::button::target_outset(self.cached_bounds.get().size(), kind, tokens)
394    }
395
396    /// The strip paints two things on one canvas: the gradient it is, and the
397    /// thumb marking the chosen hue. The node is the target — a press
398    /// anywhere on it jumps the value — and the thumb is the grab affordance
399    /// the user aims at, which nothing else in the tree can see because it is
400    /// drawn rather than laid out. Derived exactly as `paint` derives it.
401    fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
402        use crate::styles::recipe_color_picker_style as cp;
403        use teksilo_core::partition::TargetRegion;
404
405        let t = (self.hue.get() / 360.0).clamp(0.0, 1.0);
406        let thumb = match self.orientation {
407            Orientation::Vertical => Rect::new(
408                bounds.x - 2.0,
409                bounds.y + bounds.height * t - cp::STRIP_THUMB_HEIGHT * 0.5,
410                bounds.width + 4.0,
411                cp::STRIP_THUMB_HEIGHT,
412            ),
413            Orientation::Horizontal => Rect::new(
414                bounds.x + bounds.width * t - cp::STRIP_THUMB_WIDTH * 0.5,
415                bounds.y - 2.0,
416                cp::STRIP_THUMB_WIDTH,
417                bounds.height + 4.0,
418            ),
419        };
420        vec![
421            TargetRegion::target(bounds, 0),
422            TargetRegion::grab(thumb, 1),
423        ]
424    }
425
426    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
427        builder.set_role(Role::Slider);
428        if !self.label.is_empty() {
429            builder.set_name(&self.label);
430        }
431        builder.set_numeric_value(self.hue.get() as f64);
432        builder.set_min_numeric_value(0.0);
433        builder.set_max_numeric_value(360.0);
434        builder.set_numeric_value_step(1.0);
435        builder.set_numeric_value_jump(15.0);
436        let orientation = match self.orientation {
437            Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
438            Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
439        };
440        builder.set_orientation(orientation);
441        // Framework a11y walker sets `set_disabled` from arena state.
442        builder.add_action(Action::Increment);
443        builder.add_action(Action::Decrement);
444        builder.add_action(Action::Focus);
445    }
446}
447
448/// Generate a 256×1 RGBA rainbow texture. Each pixel `i` is the color
449/// `Color::from_hsv(i/256·360, 1, 1)` packed as four `u8`s.
450fn generate_hue_pixels() -> Vec<u8> {
451    let mut pixels = Vec::with_capacity(HUE_TEXTURE_LENGTH as usize * 4);
452    for i in 0..HUE_TEXTURE_LENGTH {
453        let h = (i as f32 / HUE_TEXTURE_LENGTH as f32) * 360.0;
454        let c = Color::from_hsv(h, 1.0, 1.0);
455        pixels.push((c.r() * 255.0) as u8);
456        pixels.push((c.g() * 255.0) as u8);
457        pixels.push((c.b() * 255.0) as u8);
458        pixels.push(255);
459    }
460    pixels
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn hue_pixels_are_rainbow() {
469        let pixels = generate_hue_pixels();
470        assert_eq!(pixels.len(), HUE_TEXTURE_LENGTH as usize * 4);
471        // First pixel is red.
472        assert_eq!(pixels[0], 255);
473        assert_eq!(pixels[1], 0);
474        assert_eq!(pixels[2], 0);
475        // Middle pixel ≈ cyan.
476        let mid = (HUE_TEXTURE_LENGTH as usize / 2) * 4;
477        assert!(pixels[mid] < 50);
478        assert!(pixels[mid + 1] > 200);
479        assert!(pixels[mid + 2] > 200);
480    }
481}