Skip to main content

teksilo_widgets/color_picker/
alpha_strip.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `AlphaStrip` — 1D opacity slider with a checkerboard background +
5//! tinted gradient overlay.
6//!
7//! Background: a checkerboard pattern in two neutral grays (so the
8//! transparency reads correctly against any underlying surface). The
9//! foreground is a `Paint::LinearGradient` fading from
10//! `current_color.with_alpha(0.0)` to `current_color.with_alpha(1.0)`
11//! along the strip's primary axis. Vertical orientation reads
12//! transparent at the top (alpha=0) and opaque at the bottom (alpha=1);
13//! horizontal reads transparent on the leading edge.
14//!
15//! Accessibility is `Role::Slider`, name "Opacity", `numeric_value =
16//! alpha × 100`, range `0..100`, step 1, jump 10.
17
18use std::cell::Cell;
19use std::rc::Rc;
20
21use teksilo_canvas::paint::GradientStop;
22use teksilo_canvas::{Canvas, Paint, Point, Rect, Size, SizeProposal};
23use teksilo_core::accessibility::AccessNodeBuilder;
24use teksilo_core::accesskit::{Action, Role};
25use teksilo_core::build_context::BuildContext;
26use teksilo_core::event::{EventResponse, Key, PointerButton, WidgetEvent};
27use teksilo_core::focus::FocusOrigin;
28use teksilo_core::gesture::DragPhase;
29use teksilo_core::signal::Signal;
30use teksilo_core::widget::{
31    CursorIcon, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
32};
33use teksilo_core::widget_builder::HandlerSet;
34use teksilo_core::widget_id::WidgetId;
35use teksilo_tokens::{Color, CornerRadius, Orientation};
36
37pub(crate) struct AlphaStrip {
38    /// Current bound color — for the foreground gradient color.
39    current_color: Signal<Color>,
40    alpha: Signal<f32>,
41    set_alpha: Rc<dyn Fn(f32)>,
42    dragging: Rc<Cell<bool>>,
43    cached_bounds: Rc<Cell<Rect>>,
44    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
45    orientation: Orientation,
46    /// Initial enabled-state; forwarded to the arena at build time.
47    initial_enabled: bool,
48    label: String,
49}
50
51impl AlphaStrip {
52    pub(crate) fn new(
53        current_color: Signal<Color>,
54        alpha: Signal<f32>,
55        set_alpha: Rc<dyn Fn(f32)>,
56        dragging: Rc<Cell<bool>>,
57    ) -> Self {
58        Self {
59            current_color,
60            alpha,
61            set_alpha,
62            dragging,
63            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
64            focus_origin: Rc::new(Cell::new(None)),
65            orientation: Orientation::Vertical,
66            initial_enabled: true,
67            label: String::new(),
68        }
69    }
70
71    pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
72        self.orientation = orientation;
73        self
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    pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
83        self.label = label.into();
84        self
85    }
86}
87
88impl std::fmt::Debug for AlphaStrip {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("AlphaStrip").finish_non_exhaustive()
91    }
92}
93
94impl Widget for AlphaStrip {
95    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
96        let self_id = ctx.self_id();
97        // Forward initial-enabled into the arena; see IconButton.
98        if !self.initial_enabled {
99            ctx.enabled_when(self_id, false);
100        }
101        let registry = ctx.binding_registry();
102        // Color drives the gradient colour; alpha drives the thumb position.
103        self.current_color.bind_to(
104            self_id,
105            registry,
106            teksilo_core::binding::BindingLevel::RepaintOnly,
107        );
108        self.alpha.bind_to(
109            self_id,
110            registry,
111            teksilo_core::binding::BindingLevel::RepaintOnly,
112        );
113
114        // Framework gates events on `arena.is_enabled(self_id)` — the
115        // pre-arena `enabled` snapshot is gone.
116        let cached_bounds = self.cached_bounds.clone();
117        let dragging = self.dragging.clone();
118        let set_alpha = self.set_alpha.clone();
119        let orientation = self.orientation;
120
121        let apply: Rc<dyn Fn(f32, f32)> = {
122            let set_alpha = set_alpha.clone();
123            Rc::new(move |x: f32, y: f32| {
124                let bounds = cached_bounds.get();
125                // `x` / `y` arrive widget-local (origin at the strip's own
126                // top-left), so no `bounds.x` / `bounds.y` subtraction.
127                let t = match orientation {
128                    Orientation::Vertical => {
129                        if bounds.height <= 0.0 {
130                            return;
131                        }
132                        (y / bounds.height).clamp(0.0, 1.0)
133                    }
134                    Orientation::Horizontal => {
135                        if bounds.width <= 0.0 {
136                            return;
137                        }
138                        (x / bounds.width).clamp(0.0, 1.0)
139                    }
140                };
141                // Visual: top/leading = transparent (alpha 0), bottom/trailing = opaque (alpha 1).
142                (set_alpha)(t.clamp(0.0, 1.0));
143            })
144        };
145
146        let mut handlers = HandlerSet::new()
147            .focusable(true)
148            .cursor(CursorIcon::Pointer);
149
150        {
151            let dragging = dragging.clone();
152            let apply = apply.clone();
153            handlers = handlers.on_drag(move |phase, _ctx| match phase {
154                DragPhase::Started {
155                    position,
156                    button: PointerButton::Primary,
157                } => {
158                    dragging.set(true);
159                    apply(position.x, position.y);
160                }
161                DragPhase::Moved { position, .. } if dragging.get() => {
162                    apply(position.x, position.y);
163                }
164                DragPhase::Ended { .. } => {
165                    dragging.set(false);
166                }
167                _ => {}
168            });
169        }
170        {
171            let apply = apply.clone();
172            handlers = handlers.on_tap(move |event, _ctx| {
173                apply(event.position.x, event.position.y);
174            });
175        }
176
177        // Keyboard.
178        {
179            let set_alpha = set_alpha.clone();
180            let alpha = self.alpha.clone();
181            handlers = handlers.on_key(move |event, _ctx| {
182                let WidgetEvent::KeyDown { key, .. } = event else {
183                    return EventResponse::Ignored;
184                };
185                match key {
186                    Key::ArrowUp | Key::ArrowRight => {
187                        (set_alpha)((alpha.get() + 0.01).clamp(0.0, 1.0));
188                        EventResponse::Handled
189                    }
190                    Key::ArrowDown | Key::ArrowLeft => {
191                        (set_alpha)((alpha.get() - 0.01).clamp(0.0, 1.0));
192                        EventResponse::Handled
193                    }
194                    Key::PageUp => {
195                        (set_alpha)((alpha.get() + 0.10).clamp(0.0, 1.0));
196                        EventResponse::Handled
197                    }
198                    Key::PageDown => {
199                        (set_alpha)((alpha.get() - 0.10).clamp(0.0, 1.0));
200                        EventResponse::Handled
201                    }
202                    Key::Home => {
203                        (set_alpha)(0.0);
204                        EventResponse::Handled
205                    }
206                    Key::End => {
207                        (set_alpha)(1.0);
208                        EventResponse::Handled
209                    }
210                    _ => EventResponse::Ignored,
211                }
212            });
213        }
214
215        {
216            let focus_origin = self.focus_origin.clone();
217            handlers = handlers.on_focus(move |gained, _ctx| {
218                focus_origin.set(if gained {
219                    Some(FocusOrigin::Keyboard)
220                } else {
221                    None
222                });
223            });
224        }
225
226        {
227            let set_alpha = set_alpha.clone();
228            let alpha = self.alpha.clone();
229            handlers = handlers.on_access_action(move |action, _ctx| match action {
230                Action::Increment => {
231                    (set_alpha)((alpha.get() + 0.01).clamp(0.0, 1.0));
232                    EventResponse::Handled
233                }
234                Action::Decrement => {
235                    (set_alpha)((alpha.get() - 0.01).clamp(0.0, 1.0));
236                    EventResponse::Handled
237                }
238                _ => EventResponse::Ignored,
239            });
240        }
241
242        ctx.apply_self_handlers(handlers);
243        Vec::new()
244    }
245
246    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
247        use crate::styles::recipe_color_picker_style as cp;
248        let size = match self.orientation {
249            Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
250            Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
251        };
252        size.into()
253    }
254
255    fn place_children(
256        &self,
257        bounds: Rect,
258        _proposal: SizeProposal,
259        _children: &mut [WidgetPlacement],
260        _ctx: &LayoutContext,
261    ) {
262        self.cached_bounds.set(bounds);
263    }
264
265    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
266        use crate::styles::recipe_color_picker_style as cp;
267        self.cached_bounds.set(bounds);
268        let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
269
270        // Checkerboard background — many small fill_rect calls. For a
271        // 14×192 strip with 6 px cells that's ~64 calls per paint, well
272        // within the Tier-1 budget. Drawn first; the gradient overlay
273        // handles transparency.
274        paint_checkerboard(
275            canvas,
276            bounds,
277            cp::CHECKER_CELL,
278            cp::CHECKER_COLOR_A,
279            cp::CHECKER_COLOR_B,
280        );
281
282        // Gradient overlay — current color from transparent to opaque
283        // along the primary axis. `Paint::LinearGradient` endpoints
284        // are rect-local (origin at the rect's top-left); see
285        // `Paint::LinearGradient` docs for the rationale.
286        let opaque = self.current_color.get().with_alpha(1.0);
287        let transparent = opaque.with_alpha(0.0);
288        let (start, end) = match self.orientation {
289            Orientation::Vertical => (Point::new(0.0, 0.0), Point::new(0.0, bounds.height)),
290            Orientation::Horizontal => (Point::new(0.0, 0.0), Point::new(bounds.width, 0.0)),
291        };
292        canvas.fill_rounded_rect(
293            bounds,
294            radius,
295            Paint::LinearGradient {
296                start,
297                end,
298                stops: vec![
299                    GradientStop {
300                        offset: 0.0,
301                        color: transparent,
302                    },
303                    GradientStop {
304                        offset: 1.0,
305                        color: opaque,
306                    },
307                ],
308            },
309        );
310
311        // Border frame.
312        canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
313
314        // Thumb.
315        let t = self.alpha.get().clamp(0.0, 1.0);
316        let thumb_w = cp::STRIP_THUMB_WIDTH;
317        let thumb_h = cp::STRIP_THUMB_HEIGHT;
318        let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
319        let thumb_rect = match self.orientation {
320            Orientation::Vertical => Rect::new(
321                bounds.x - 2.0,
322                bounds.y + bounds.height * t - thumb_h * 0.5,
323                bounds.width + 4.0,
324                thumb_h,
325            ),
326            Orientation::Horizontal => Rect::new(
327                bounds.x + bounds.width * t - 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.alpha.get() * 100.0) as f64);
348        builder.set_min_numeric_value(0.0);
349        builder.set_max_numeric_value(100.0);
350        builder.set_numeric_value_step(1.0);
351        builder.set_numeric_value_jump(10.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/// Tile a two-color checkerboard inside `bounds` using `cell`-pixel
365/// squares. Handles partial trailing cells so the pattern reads
366/// correctly at non-integer multiples of `cell`.
367pub(crate) fn paint_checkerboard(
368    canvas: &mut Canvas,
369    bounds: Rect,
370    cell: f32,
371    color_a: Color,
372    color_b: Color,
373) {
374    if cell <= 0.0 || bounds.width <= 0.0 || bounds.height <= 0.0 {
375        return;
376    }
377    let cols = (bounds.width / cell).ceil() as i32;
378    let rows = (bounds.height / cell).ceil() as i32;
379    for row in 0..rows {
380        for col in 0..cols {
381            let dark = (row + col) & 1 == 1;
382            let color = if dark { color_a } else { color_b };
383            let x = bounds.x + col as f32 * cell;
384            let y = bounds.y + row as f32 * cell;
385            let w = (bounds.x + bounds.width - x).min(cell);
386            let h = (bounds.y + bounds.height - y).min(cell);
387            if w > 0.0 && h > 0.0 {
388                canvas.fill_rect(Rect::new(x, y, w, h), color);
389            }
390        }
391    }
392}