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//! opaque at the top (alpha=1) and transparent at the bottom (alpha=0);
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, PointerButton, WidgetEvent};
27use teksilo_core::focus::FocusOrigin;
28use teksilo_core::gesture::DragPhase;
29use teksilo_core::pointer::touch_action::TouchAction;
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
38use crate::common::range_nav::{self, RangeAxis, RangeKind, RangeMove};
39
40pub(crate) struct AlphaStrip {
41    /// Current bound color — for the foreground gradient color.
42    current_color: Signal<Color>,
43    alpha: Signal<f32>,
44    set_alpha: Rc<dyn Fn(f32)>,
45    dragging: Rc<Cell<bool>>,
46    cached_bounds: Rc<Cell<Rect>>,
47    focus_origin: Rc<Cell<Option<FocusOrigin>>>,
48    orientation: Orientation,
49    /// Initial enabled-state; forwarded to the arena at build time.
50    initial_enabled: bool,
51    label: String,
52}
53
54impl AlphaStrip {
55    pub(crate) fn new(
56        current_color: Signal<Color>,
57        alpha: Signal<f32>,
58        set_alpha: Rc<dyn Fn(f32)>,
59        dragging: Rc<Cell<bool>>,
60    ) -> Self {
61        Self {
62            current_color,
63            alpha,
64            set_alpha,
65            dragging,
66            cached_bounds: Rc::new(Cell::new(Rect::ZERO)),
67            focus_origin: Rc::new(Cell::new(None)),
68            orientation: Orientation::Vertical,
69            initial_enabled: true,
70            label: String::new(),
71        }
72    }
73
74    pub(crate) fn orientation(mut self, orientation: Orientation) -> Self {
75        self.orientation = orientation;
76        self
77    }
78
79    /// Set the initial enabled state. Forwarded to the arena at build time.
80    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
81        self.initial_enabled = enabled;
82        self
83    }
84
85    pub(crate) fn label(mut self, label: impl Into<String>) -> Self {
86        self.label = label.into();
87        self
88    }
89}
90
91impl std::fmt::Debug for AlphaStrip {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("AlphaStrip").finish_non_exhaustive()
94    }
95}
96
97impl Widget for AlphaStrip {
98    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
99        let self_id = ctx.self_id();
100        // Forward initial-enabled into the arena; see IconButton.
101        if !self.initial_enabled {
102            ctx.enabled_when(self_id, false);
103        }
104        let registry = ctx.binding_registry();
105        // Color drives the gradient colour; alpha drives the thumb position.
106        self.current_color.bind_to(
107            self_id,
108            registry,
109            teksilo_core::binding::BindingLevel::RepaintOnly,
110        );
111        self.alpha.bind_to(
112            self_id,
113            registry,
114            teksilo_core::binding::BindingLevel::RepaintOnly,
115        );
116
117        // Framework gates events on `arena.is_enabled(self_id)` — the
118        // pre-arena `enabled` snapshot is gone.
119        let cached_bounds = self.cached_bounds.clone();
120        let dragging = self.dragging.clone();
121        let set_alpha = self.set_alpha.clone();
122        let orientation = self.orientation;
123
124        let apply: Rc<dyn Fn(f32, f32)> = {
125            let set_alpha = set_alpha.clone();
126            Rc::new(move |x: f32, y: f32| {
127                let bounds = cached_bounds.get();
128                // `x` / `y` arrive widget-local (origin at the strip's own
129                // top-left), so no `bounds.x` / `bounds.y` subtraction.
130                let t = match orientation {
131                    // `y` grows downward and the maximum is at the top, so the
132                    // pointer's ratio is the value's complement — the same
133                    // mirror `Slider` applies on its vertical axis, and what
134                    // keeps `ArrowUp` and a drag upward moving the same way.
135                    Orientation::Vertical => {
136                        if bounds.height <= 0.0 {
137                            return;
138                        }
139                        1.0 - (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                // Visual: bottom/leading = transparent (alpha 0),
149                // top/trailing = opaque (alpha 1).
150                (set_alpha)(t.clamp(0.0, 1.0));
151            })
152        };
153
154        let mut handlers = HandlerSet::new()
155            .focusable(true)
156            .cursor(CursorIcon::Pointer)
157            // A continuous manipulator: the value it produces IS the press
158            // position, so no default touch behaviour may be run on it. `NONE`
159            // freezes the hit path's touch action at the press
160            // (`docs/touch-and-pen.md` §7.3); what it forbids, with a test on
161            // it, is a two-contact pinch started on this control reaching the
162            // surface underneath. The press capture the drag takes is what
163            // separately keeps a scroller the picker sits in from taking the
164            // gesture away.
165            .touch_action(TouchAction::NONE);
166
167        {
168            let dragging = dragging.clone();
169            let apply = apply.clone();
170            handlers = handlers.on_drag(move |phase, _ctx| match phase {
171                DragPhase::Started {
172                    position,
173                    button: PointerButton::Primary,
174                    ..
175                } => {
176                    dragging.set(true);
177                    apply(position.x, position.y);
178                }
179                DragPhase::Moved { position, .. } if dragging.get() => {
180                    apply(position.x, position.y);
181                }
182                DragPhase::Ended { .. } => {
183                    dragging.set(false);
184                }
185                _ => {}
186            });
187        }
188        {
189            let apply = apply.clone();
190            handlers = handlers.on_tap(move |event, _ctx| {
191                apply(event.position.x, event.position.y);
192            });
193        }
194
195        // Keyboard.
196        {
197            let set_alpha = set_alpha.clone();
198            let alpha = self.alpha.clone();
199            handlers = handlers.on_key(move |event, _ctx| {
200                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
201                    return EventResponse::Ignored;
202                };
203                // `rtl` is false: the checkerboard gradient and the pointer
204                // mapping both run leading-to-trailing unconditionally.
205                let Some(mv) = range_nav::range_move(
206                    *key,
207                    *modifiers,
208                    RangeKind::Scalar,
209                    RangeAxis::Both,
210                    false,
211                ) else {
212                    return EventResponse::Ignored;
213                };
214                // The magnitudes stay in agreement with the
215                // `numeric_value_step` / `numeric_value_jump` published below.
216                let next = match mv {
217                    RangeMove::Step { increase } => {
218                        alpha.get() + if increase { 0.01 } else { -0.01 }
219                    }
220                    RangeMove::Page { increase } => {
221                        alpha.get() + if increase { 0.10 } else { -0.10 }
222                    }
223                    RangeMove::ToMin => 0.0,
224                    RangeMove::ToMax => 1.0,
225                };
226                (set_alpha)(next.clamp(0.0, 1.0));
227                EventResponse::Handled
228            });
229        }
230
231        {
232            let focus_origin = self.focus_origin.clone();
233            handlers = handlers.on_focus(move |gained, _ctx| {
234                focus_origin.set(if gained {
235                    Some(FocusOrigin::Keyboard)
236                } else {
237                    None
238                });
239            });
240        }
241
242        {
243            let set_alpha = set_alpha.clone();
244            let alpha = self.alpha.clone();
245            handlers = handlers.on_access_action(move |action, _ctx| match action {
246                Action::Increment => {
247                    (set_alpha)((alpha.get() + 0.01).clamp(0.0, 1.0));
248                    EventResponse::Handled
249                }
250                Action::Decrement => {
251                    (set_alpha)((alpha.get() - 0.01).clamp(0.0, 1.0));
252                    EventResponse::Handled
253                }
254                _ => EventResponse::Ignored,
255            });
256        }
257
258        ctx.apply_self_handlers(handlers);
259        Vec::new()
260    }
261
262    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
263        use crate::styles::recipe_color_picker_style as cp;
264        let size = match self.orientation {
265            Orientation::Vertical => Size::new(cp::STRIP_THICKNESS, cp::STRIP_LENGTH),
266            Orientation::Horizontal => Size::new(cp::STRIP_LENGTH, cp::STRIP_THICKNESS),
267        };
268        size.into()
269    }
270
271    fn place_children(
272        &self,
273        bounds: Rect,
274        _proposal: SizeProposal,
275        _children: &mut [WidgetPlacement],
276        _ctx: &LayoutContext,
277    ) {
278        self.cached_bounds.set(bounds);
279    }
280
281    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
282        use crate::styles::recipe_color_picker_style as cp;
283        self.cached_bounds.set(bounds);
284        let radius = CornerRadius::uniform(cp::STRIP_CORNER_RADIUS);
285
286        // Checkerboard background — many small fill_rect calls. For a
287        // 14×192 strip with 6 px cells that's ~96 calls per paint (3 cols ×
288        // 32 rows), well within the Tier-1 budget. Drawn first; the gradient
289        // overlay handles transparency.
290        paint_checkerboard(
291            canvas,
292            bounds,
293            cp::CHECKER_CELL,
294            cp::CHECKER_COLOR_A,
295            cp::CHECKER_COLOR_B,
296        );
297
298        // Gradient overlay — current color from transparent to opaque
299        // along the primary axis. `Paint::LinearGradient` endpoints
300        // are rect-local (origin at the rect's top-left); see
301        // `Paint::LinearGradient` docs for the rationale.
302        let opaque = self.current_color.get().with_alpha(1.0);
303        let transparent = opaque.with_alpha(0.0);
304        let (start, end) = match self.orientation {
305            // Bottom is transparent and top opaque: a vertical bounded scalar's
306            // maximum is at the top, so the fully opaque end has to be the one
307            // `ArrowUp` and an upward drag travel towards.
308            Orientation::Vertical => (Point::new(0.0, bounds.height), Point::new(0.0, 0.0)),
309            Orientation::Horizontal => (Point::new(0.0, 0.0), Point::new(bounds.width, 0.0)),
310        };
311        canvas.fill_rounded_rect(
312            bounds,
313            radius,
314            Paint::LinearGradient {
315                start,
316                end,
317                stops: vec![
318                    GradientStop {
319                        offset: 0.0,
320                        color: transparent,
321                    },
322                    GradientStop {
323                        offset: 1.0,
324                        color: opaque,
325                    },
326                ],
327            },
328        );
329
330        // Border frame.
331        canvas.stroke_rounded_rect(bounds, radius, ctx.theme.colors.border, 1.0);
332
333        // Thumb.
334        let t = self.alpha.get().clamp(0.0, 1.0);
335        let thumb_w = cp::STRIP_THUMB_WIDTH;
336        let thumb_h = cp::STRIP_THUMB_HEIGHT;
337        let thumb_radius = CornerRadius::uniform(cp::STRIP_THUMB_CORNER_RADIUS);
338        let thumb_rect = match self.orientation {
339            // Maximum at the top, so the thumb travels up as the alpha grows.
340            Orientation::Vertical => Rect::new(
341                bounds.x - 2.0,
342                bounds.bottom() - bounds.height * t - thumb_h * 0.5,
343                bounds.width + 4.0,
344                thumb_h,
345            ),
346            Orientation::Horizontal => Rect::new(
347                bounds.x + bounds.width * t - thumb_w * 0.5,
348                bounds.y - 2.0,
349                thumb_w,
350                bounds.height + 4.0,
351            ),
352        };
353        canvas.fill_rounded_rect(thumb_rect, thumb_radius, Color::WHITE);
354        canvas.stroke_rounded_rect(
355            thumb_rect,
356            thumb_radius,
357            Color::new(0.0, 0.0, 0.0, 0.5),
358            1.0,
359        );
360    }
361
362    /// A 14 dp strip is well under the 24 dp conformance floor across its short
363    /// axis, and it cannot grow: the picker's column geometry is built around
364    /// it, so widening the paint at Compact would move the whole panel. The
365    /// shortfall is made up between the pointer and the arena instead. The long
366    /// axis is already generous and takes nothing, which also keeps the two
367    /// strips from reaching into the controls above and below them.
368    fn hit_outset(
369        &self,
370        kind: teksilo_tokens::PointerKind,
371        tokens: &teksilo_tokens::InputTokens,
372    ) -> teksilo_canvas::EdgeInsets {
373        crate::button::target_outset(self.cached_bounds.get().size(), kind, tokens)
374    }
375
376    /// The strip paints two things on one canvas: the gradient it is, and the
377    /// thumb marking the chosen opacity. The node is the target — a press
378    /// anywhere on it jumps the value — and the thumb is the grab affordance
379    /// the user aims at, which nothing else in the tree can see because it is
380    /// drawn rather than laid out. Derived exactly as `paint` derives it.
381    fn target_regions(&self, bounds: Rect) -> Vec<teksilo_core::partition::TargetRegion> {
382        use crate::styles::recipe_color_picker_style as cp;
383        use teksilo_core::partition::TargetRegion;
384
385        let t = self.alpha.get().clamp(0.0, 1.0);
386        let thumb = match self.orientation {
387            Orientation::Vertical => Rect::new(
388                bounds.x - 2.0,
389                bounds.y + bounds.height * t - cp::STRIP_THUMB_HEIGHT * 0.5,
390                bounds.width + 4.0,
391                cp::STRIP_THUMB_HEIGHT,
392            ),
393            Orientation::Horizontal => Rect::new(
394                bounds.x + bounds.width * t - cp::STRIP_THUMB_WIDTH * 0.5,
395                bounds.y - 2.0,
396                cp::STRIP_THUMB_WIDTH,
397                bounds.height + 4.0,
398            ),
399        };
400        vec![
401            TargetRegion::target(bounds, 0),
402            TargetRegion::grab(thumb, 1),
403        ]
404    }
405
406    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
407        builder.set_role(Role::Slider);
408        if !self.label.is_empty() {
409            builder.set_name(&self.label);
410        }
411        builder.set_numeric_value((self.alpha.get() * 100.0) as f64);
412        builder.set_min_numeric_value(0.0);
413        builder.set_max_numeric_value(100.0);
414        builder.set_numeric_value_step(1.0);
415        builder.set_numeric_value_jump(10.0);
416        let orientation = match self.orientation {
417            Orientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
418            Orientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
419        };
420        builder.set_orientation(orientation);
421        // Framework a11y walker sets `set_disabled` from arena state.
422        builder.add_action(Action::Increment);
423        builder.add_action(Action::Decrement);
424        builder.add_action(Action::Focus);
425    }
426}
427
428/// Tile a two-color checkerboard inside `bounds` using `cell`-pixel
429/// squares. Handles partial trailing cells so the pattern reads
430/// correctly at non-integer multiples of `cell`.
431pub(crate) fn paint_checkerboard(
432    canvas: &mut Canvas,
433    bounds: Rect,
434    cell: f32,
435    color_a: Color,
436    color_b: Color,
437) {
438    if cell <= 0.0 || bounds.width <= 0.0 || bounds.height <= 0.0 {
439        return;
440    }
441    let cols = (bounds.width / cell).ceil() as i32;
442    let rows = (bounds.height / cell).ceil() as i32;
443    for row in 0..rows {
444        for col in 0..cols {
445            let dark = (row + col) & 1 == 1;
446            let color = if dark { color_a } else { color_b };
447            let x = bounds.x + col as f32 * cell;
448            let y = bounds.y + row as f32 * cell;
449            let w = (bounds.x + bounds.width - x).min(cell);
450            let h = (bounds.y + bounds.height - y).min(cell);
451            if w > 0.0 && h > 0.0 {
452                canvas.fill_rect(Rect::new(x, y, w, h), color);
453            }
454        }
455    }
456}