Skip to main content

agg_gui/widgets/
toggle_switch.rs

1//! `ToggleSwitch` — an iOS-style pill-shaped boolean toggle widget.
2//!
3//! Renders as a rounded-rectangle (pill) with a sliding white circle inside.
4//! The pill is gray when off and blue when on.  Supports keyboard activation
5//! (Space / Enter) and an optional shared [`Cell<bool>`] for two-way binding
6//! with external state.
7
8use std::cell::Cell;
9use std::rc::Rc;
10
11use crate::color::Color;
12use crate::draw_ctx::DrawCtx;
13use crate::event::{Event, EventResult, Key, MouseButton};
14use crate::geometry::{Rect, Size};
15use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
16use crate::widget::Widget;
17
18// ── Geometry constants ─────────────────────────────────────────────────────
19//
20// Sized to fit within a typical 16-18 px text line (13-14 px font) so the
21// switch sits flush beside a label without inflating the row height.
22
23const PILL_W: f64 = 32.0;
24const PILL_H: f64 = 18.0;
25/// Corner radius of the pill — a full semicircle on each end.
26const PILL_R: f64 = PILL_H / 2.0;
27/// Gap between the pill edge and the circle edge.
28const CIRCLE_MARGIN: f64 = 2.5;
29/// Circle radius derived from pill height and the margin.
30const CIRCLE_R: f64 = PILL_H / 2.0 - CIRCLE_MARGIN;
31/// Duration of the on/off slide animation in seconds.
32const ANIM_SECS: f64 = 0.14;
33/// Inset on each side between the widget's outer bounds and the pill
34/// geometry.  The halo-AA pipeline extrudes the pill's filled edges one
35/// pixel outward; without a margin that halo sits outside the widget's
36/// own bounds and gets clipped by the parent's `clip_rect(0, 0, w, h)` —
37/// the bottom edge loses its AA fade and looks flat-cut.  One pixel is
38/// enough to keep the full halo inside the clip.
39const PILL_HALO: f64 = 1.0;
40
41// ── Press-ring overlay ───────────────────────────────────────────────────
42//
43// Matches MatterCAD's `RoundedToggleSwitch`: on mouse-down a translucent
44// disc centred on the toggle circle expands outward; on mouse-up it fades
45// back.  The MatterCAD version used a radius ratio of ~2.44× the circle
46// radius (22 vs 9 px) and ~50/255 alpha with quadratic ease-out.
47
48/// Maximum radius of the press-ring overlay (~2.4× the circle radius).
49const RING_MAX_R: f64 = CIRCLE_R * 2.4;
50/// Peak alpha of the press-ring at full expansion.
51const RING_PEAK_ALPHA: f32 = 0.20;
52/// Duration of the press-ring expand / retract animation in seconds.
53const RING_ANIM_SECS: f64 = 0.22;
54
55// Colors are resolved from ctx.visuals() at paint time.
56
57// ── Struct ─────────────────────────────────────────────────────────────────
58
59/// Inspector-visible properties of a [`ToggleSwitch`].
60#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
61#[derive(Clone, Debug, Default)]
62pub struct ToggleSwitchProps {
63    /// Internal on/off state, used when `state_cell` is `None`.
64    pub on: bool,
65}
66
67/// An iOS-style boolean toggle.
68///
69/// Displays a pill-shaped background that switches from gray (off) to blue (on)
70/// with a white circle that slides to the opposite end.
71pub struct ToggleSwitch {
72    bounds: Rect,
73    children: Vec<Box<dyn Widget>>, // always empty
74    base: WidgetBase,
75    pub props: ToggleSwitchProps,
76    /// When set, this cell is the authoritative state; `paint` reads from it
77    /// and `toggle` writes to it so external changes are reflected immediately.
78    state_cell: Option<Rc<Cell<bool>>>,
79    hovered: bool,
80    /// Interpolates between 0.0 (off) and 1.0 (on) for smooth colour/circle
81    /// position transitions; driven by `animation::Tween`.
82    anim: crate::animation::Tween,
83    pressed: bool,
84    /// Interpolates 0.0 → 1.0 while the mouse is pressed (ring expand) and
85    /// back to 0.0 on release (ring fade).  Mirrors MatterCAD's
86    /// `RoundedToggleSwitch` ripple overlay.
87    press_anim: crate::animation::Tween,
88    on_change: Option<Box<dyn FnMut(bool)>>,
89}
90
91// ── Constructors & builder methods ─────────────────────────────────────────
92
93impl ToggleSwitch {
94    /// Create a new toggle switch with an initial on/off state.
95    pub fn new(on: bool) -> Self {
96        let initial = if on { 1.0 } else { 0.0 };
97        Self {
98            bounds: Rect::default(),
99            children: Vec::new(),
100            base: WidgetBase::new(),
101            props: ToggleSwitchProps { on },
102            state_cell: None,
103            hovered: false,
104            anim: crate::animation::Tween::new(initial, ANIM_SECS),
105            pressed: false,
106            press_anim: crate::animation::Tween::new(0.0, RING_ANIM_SECS),
107            on_change: None,
108        }
109    }
110
111    /// Bind the toggle state to a shared [`Cell<bool>`].
112    ///
113    /// When set, `paint` reads from the cell (so external writes are reflected
114    /// immediately) and `toggle` writes to it in both directions.
115    pub fn with_state_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
116        self.state_cell = Some(cell);
117        self
118    }
119
120    /// Register a callback invoked with the new state whenever the switch
121    /// is toggled.
122    pub fn on_change(mut self, cb: impl FnMut(bool) + 'static) -> Self {
123        self.on_change = Some(Box::new(cb));
124        self
125    }
126
127    pub fn with_margin(mut self, m: Insets) -> Self {
128        self.base.margin = m;
129        self
130    }
131    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
132        self.base.h_anchor = h;
133        self
134    }
135    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
136        self.base.v_anchor = v;
137        self
138    }
139    pub fn with_min_size(mut self, s: Size) -> Self {
140        self.base.min_size = s;
141        self
142    }
143    pub fn with_max_size(mut self, s: Size) -> Self {
144        self.base.max_size = s;
145        self
146    }
147
148    // ── State accessors ────────────────────────────────────────────────────
149
150    /// Returns the authoritative on/off state: the cell value if bound,
151    /// otherwise the internal `on` field.
152    pub fn is_on(&self) -> bool {
153        if let Some(ref cell) = self.state_cell {
154            cell.get()
155        } else {
156            self.props.on
157        }
158    }
159
160    // ── Internal helpers ───────────────────────────────────────────────────
161
162    fn toggle(&mut self) {
163        let new_val = !self.is_on();
164        self.props.on = new_val;
165        if let Some(ref cell) = self.state_cell {
166            cell.set(new_val);
167        }
168        if let Some(cb) = self.on_change.as_mut() {
169            cb(new_val);
170        }
171    }
172
173    /// X-center of the sliding circle given an interpolated position `t`
174    /// in `[0, 1]` (0 = off, 1 = on).  Expressed in widget-local coords,
175    /// so the `PILL_HALO` inset is baked in — callers don't need to know
176    /// about it.
177    fn circle_cx_at(t: f64) -> f64 {
178        let x_off = PILL_HALO + CIRCLE_MARGIN + CIRCLE_R;
179        let x_on = PILL_HALO + PILL_W - CIRCLE_MARGIN - CIRCLE_R;
180        x_off + (x_on - x_off) * t.clamp(0.0, 1.0)
181    }
182}
183
184/// Linear interpolation between two colours, component-wise.
185fn lerp_color(a: Color, b: Color, t: f32) -> Color {
186    let t = t.clamp(0.0, 1.0);
187    Color::rgba(
188        a.r + (b.r - a.r) * t,
189        a.g + (b.g - a.g) * t,
190        a.b + (b.b - a.b) * t,
191        a.a + (b.a - a.a) * t,
192    )
193}
194
195// ── Widget impl ────────────────────────────────────────────────────────────
196
197impl Widget for ToggleSwitch {
198    fn type_name(&self) -> &'static str {
199        "ToggleSwitch"
200    }
201
202    fn bounds(&self) -> Rect {
203        self.bounds
204    }
205    fn set_bounds(&mut self, b: Rect) {
206        self.bounds = b;
207    }
208    fn children(&self) -> &[Box<dyn Widget>] {
209        &self.children
210    }
211    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
212        &mut self.children
213    }
214
215    #[cfg(feature = "reflect")]
216    fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
217        Some(&self.props)
218    }
219    #[cfg(feature = "reflect")]
220    fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
221        Some(&mut self.props)
222    }
223    fn is_focusable(&self) -> bool {
224        true
225    }
226
227    fn margin(&self) -> Insets {
228        self.base.margin
229    }
230    fn widget_base(&self) -> Option<&WidgetBase> {
231        Some(&self.base)
232    }
233    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
234        Some(&mut self.base)
235    }
236    fn h_anchor(&self) -> HAnchor {
237        self.base.h_anchor
238    }
239    fn v_anchor(&self) -> VAnchor {
240        self.base.v_anchor
241    }
242    fn min_size(&self) -> Size {
243        self.base.min_size
244    }
245    fn max_size(&self) -> Size {
246        self.base.max_size
247    }
248
249    /// Always returns the fixed pill size (plus a 1 px halo margin on
250    /// every side); the available space is ignored.  See [`PILL_HALO`]
251    /// for why the margin is needed.
252    fn layout(&mut self, _available: Size) -> Size {
253        Size::new(PILL_W + 2.0 * PILL_HALO, PILL_H + 2.0 * PILL_HALO)
254    }
255
256    fn needs_draw(&self) -> bool {
257        if !self.is_visible() {
258            return false;
259        }
260        self.anim.is_animating()
261            || self.press_anim.is_animating()
262            || self.children().iter().any(|c| c.needs_draw())
263    }
264
265    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
266        let v = ctx.visuals();
267
268        // Retarget the tween each paint so external state-cell writes are
269        // picked up (e.g. a checkbox-style binding toggled from outside), then
270        // advance it to get this frame's interpolated position.
271        self.anim.set_target(if self.is_on() { 1.0 } else { 0.0 });
272        let t = self.anim.tick();
273
274        // Inset the pill by the halo margin so halo-AA has room inside
275        // the widget's own clip.  Origin (0,0) is the widget's bottom-
276        // left in Y-up; the framework has already translated there.
277        let pill_x = PILL_HALO;
278        let pill_y = PILL_HALO;
279
280        // ── Pill background ────────────────────────────────────────────────
281        // Interpolate between the off colour (gray) and the on colour (accent);
282        // a separate hover tint is applied as a multiplicative brighten.
283        let off_color = v.widget_stroke;
284        let on_color = v.accent;
285        let mut bg = lerp_color(off_color, on_color, t as f32);
286        if self.hovered {
287            let hover_off = v.widget_bg_hovered;
288            let hover_on = v.accent_hovered;
289            bg = lerp_color(hover_off, hover_on, t as f32);
290        }
291        ctx.set_fill_color(bg);
292        ctx.begin_path();
293        ctx.rounded_rect(pill_x, pill_y, PILL_W, PILL_H, PILL_R);
294        ctx.fill();
295
296        // ── Sliding white circle ───────────────────────────────────────────
297        let cx = Self::circle_cx_at(t);
298        let cy = PILL_HALO + PILL_H * 0.5;
299        ctx.set_fill_color(Color::white());
300        ctx.begin_path();
301        ctx.circle(cx, cy, CIRCLE_R);
302        ctx.fill();
303
304        // The press-ring itself is drawn in `paint_overlay` — it needs to
305        // expand beyond the widget's own bounds, which requires escaping the
306        // parent-set clip that `paint` runs under.
307    }
308
309    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
310        // ── Press-ring overlay (ripple) ────────────────────────────────────
311        // Translucent disc centred on the toggle circle.  At full expansion
312        // the ring is ~2.4× the circle radius and would be cropped by the
313        // pill-sized widget clip if drawn in `paint()`.  We therefore draw it
314        // in `paint_overlay` and temporarily lift the parent's clip via
315        // `reset_clip` so the ring can render the full ripple geometry (then
316        // `restore` puts the saved clip state back before returning).
317        let ring_t = self.press_anim.tick();
318        if ring_t <= 0.001 {
319            return;
320        }
321
322        let v = ctx.visuals();
323        let cx = Self::circle_cx_at(self.anim.value());
324        let cy = PILL_HALO + PILL_H * 0.5;
325        let toggle_color = if self.is_on() {
326            v.accent
327        } else {
328            v.widget_stroke
329        };
330        let alpha = RING_PEAK_ALPHA * (ring_t as f32);
331
332        ctx.save();
333        ctx.reset_clip();
334        ctx.set_fill_color(Color::rgba(
335            toggle_color.r,
336            toggle_color.g,
337            toggle_color.b,
338            alpha,
339        ));
340        ctx.begin_path();
341        ctx.circle(cx, cy, RING_MAX_R * ring_t);
342        ctx.fill();
343        ctx.restore();
344    }
345
346    fn on_event(&mut self, event: &Event) -> EventResult {
347        match event {
348            Event::MouseMove { pos } => {
349                let was = self.hovered;
350                self.hovered = self.hit_test(*pos);
351                if was != self.hovered {
352                    crate::animation::request_draw();
353                    return EventResult::Consumed;
354                }
355                EventResult::Ignored
356            }
357            Event::MouseDown {
358                button: MouseButton::Left,
359                ..
360            } => {
361                // Consume on down so the widget "captures" the gesture, and
362                // start the press-ring expand animation.
363                self.pressed = true;
364                self.press_anim.set_target(1.0);
365                crate::animation::request_draw();
366                EventResult::Consumed
367            }
368            Event::MouseUp {
369                button: MouseButton::Left,
370                pos,
371                ..
372            } => {
373                if self.hit_test(*pos) {
374                    self.toggle();
375                }
376                // Ring fades back out whether or not the release landed on us.
377                self.pressed = false;
378                self.press_anim.set_target(0.0);
379                crate::animation::request_draw();
380                EventResult::Consumed
381            }
382            Event::KeyDown {
383                key: Key::Char(' '),
384                ..
385            }
386            | Event::KeyDown {
387                key: Key::Enter, ..
388            } => {
389                self.toggle();
390                crate::animation::request_draw();
391                EventResult::Consumed
392            }
393            _ => EventResult::Ignored,
394        }
395    }
396
397    /// Hit test restricted to the pill bounds (matches the visible shape).
398    /// The halo margin is excluded so the ~1 px ring around the pill
399    /// doesn't register as pointer-over.
400    fn hit_test(&self, local_pos: crate::geometry::Point) -> bool {
401        local_pos.x >= PILL_HALO
402            && local_pos.x <= PILL_HALO + PILL_W
403            && local_pos.y >= PILL_HALO
404            && local_pos.y <= PILL_HALO + PILL_H
405    }
406}