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