1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx};
6use super::container::draw_rounded_rect_pub;
7
8pub struct Switch {
28 pub on: bool,
30 disabled: bool,
31 label: Option<String>,
32 width: f32,
33 height: f32,
34 on_change: Option<Arc<dyn Fn(bool) + Send + Sync>>,
35 on_color: Option<Color>,
36 off_color: Option<Color>,
37 thumb_color: Option<Color>,
38}
39
40impl Switch {
41 pub fn new(on: bool) -> Self {
42 Self {
43 on,
44 disabled: false,
45 label: None,
46 width: 44.0,
47 height: 24.0,
48 on_change: None,
49 on_color: None,
50 off_color: None,
51 thumb_color: None,
52 }
53 }
54
55 pub fn on_change(mut self, f: impl Fn(bool) + Send + Sync + 'static) -> Self {
57 self.on_change = Some(Arc::new(f));
58 self
59 }
60
61 pub fn disabled(mut self) -> Self { self.disabled = true; self }
64 pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
65
66 pub fn label(mut self, l: impl Into<String>) -> Self { self.label = Some(l.into()); self }
68
69 pub fn size(mut self, width: f32, height: f32) -> Self {
72 self.width = width;
73 self.height = height;
74 self
75 }
76
77 pub fn on_color(mut self, c: Color) -> Self { self.on_color = Some(c); self }
79 pub fn off_color(mut self, c: Color) -> Self { self.off_color = Some(c); self }
81 pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
83}
84
85fn with_alpha(c: Color, a: f32) -> Color {
86 Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
87}
88
89impl Widget for Switch {
90 fn layout(&self, ctx: &LayoutCtx) -> Size {
91 ctx.constraints.constrain(Size { width: self.width, height: self.height })
92 }
93
94 fn paint(&self, ctx: &mut PaintCtx) {
95 let mut sem = super::Semantics::new(rosace_core::Role::Switch)
97 .value(if self.on { "on" } else { "off" });
98 if let Some(l) = &self.label { sem = sem.label(l); }
99 ctx.semantics(sem);
100
101 let toggle: Arc<dyn Fn() + Send + Sync> = match (&self.on_change, self.disabled) {
104 (Some(f), false) => { let f = f.clone(); let next = !self.on; Arc::new(move || f(next)) }
105 _ => Arc::new(|| {}),
106 };
107 ctx.register_hit(toggle);
108 let focused = !self.disabled && ctx.focus_node().is_focused();
109
110 let hovered = !self.disabled && ctx.hovered();
112 let pressed = !self.disabled && ctx.pressed();
113 let t = ctx.animate_channel(0, if self.on { 1.0 } else { 0.0 }, 0.0);
115 let halo_target = if pressed { 0.16 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
116 let halo = ctx.animate_channel(1, halo_target, 0.0);
117 let press_amt = ctx.animate_channel(2, if pressed { 1.0 } else if hovered { 0.35 } else { 0.0 }, 0.0);
118
119 let colors = &ctx.theme.colors;
120 let on_track = self.on_color.unwrap_or_else(|| ctx.tc(colors.primary));
121 let off_track = self.off_color.unwrap_or_else(|| ctx.tc(colors.surface_variant));
122 let thumb_c = self.thumb_color.unwrap_or_else(|| Color::rgb(250, 250, 252));
127 let outline = ctx.tc(colors.outline);
128 let shadow = ctx.tc(colors.shadow);
129
130 let dim = if self.disabled { 0.38 } else { 1.0 };
131 let r = ctx.rect;
132 let radius = r.size.height / 2.0;
133
134 let track = super::lerp_color(off_track, on_track, t);
137 draw_rounded_rect_pub(ctx, r, with_alpha(track, dim), radius);
138 if t < 1.0 {
139 ctx.stroke_rrect(r, radius, with_alpha(outline, (1.0 - t) * dim), 1.0);
140 }
141
142 let pad = 2.0;
144 let base_r = (r.size.height / 2.0) - pad; let off_r = base_r - 2.0; let on_r = base_r; let thumb_r = off_r + (on_r - off_r) * t + press_amt * 1.5; let cy = r.origin.y + r.size.height / 2.0;
150 let off_cx = r.origin.x + pad + base_r;
151 let on_cx = r.origin.x + r.size.width - pad - base_r;
152 let cx = off_cx + (on_cx - off_cx) * t;
153
154 if halo > 0.001 {
157 let halo_color = super::lerp_color(outline, on_track, t);
158 ctx.fill_circle(Point { x: cx, y: cy }, thumb_r + 8.0, with_alpha(halo_color, halo));
159 }
160
161 let d = thumb_r * 2.0;
163 ctx.fill_shadow_rrect(
164 Rect { origin: Point { x: cx - thumb_r, y: cy - thumb_r + 1.0 }, size: Size { width: d, height: d } },
165 thumb_r,
166 with_alpha(shadow, 0.28 * dim),
167 4.0,
168 );
169 ctx.fill_circle(Point { x: cx, y: cy }, thumb_r, with_alpha(thumb_c, dim));
170 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use rosace_render::{FontCache, PictureRecorder};
180 use rosace_render::draw_command::DrawCommand;
181 use std::cell::RefCell;
182 use std::rc::Rc;
183 use crate::tree::RenderTree;
184
185 fn paint_switch(on: bool, disabled: bool) -> Vec<DrawCommand> {
186 let font = FontCache::embedded();
187 let mut rec = PictureRecorder::new();
188 let tree = Rc::new(RefCell::new(RenderTree::new()));
189 let mut ctx = PaintCtx::root(
190 &mut rec,
191 Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 44.0, height: 24.0 } },
192 &font,
193 rosace_theme::built_in::dark_theme(),
194 tree,
195 );
196 let mut s = Switch::new(on);
197 if disabled { s = s.disabled(); }
198 s.paint(&mut ctx);
199 rec.finish().commands
200 }
201
202 #[test]
203 fn default_size_is_the_premium_track() {
204 let font = FontCache::embedded();
205 let theme = rosace_theme::built_in::dark_theme();
206 let ctx = LayoutCtx::new(rosace_layout::Constraints::loose(200.0, 200.0), &font, &theme);
207 assert_eq!(Switch::new(false).layout(&ctx), Size { width: 44.0, height: 24.0 });
208 }
209
210 #[test]
211 fn paints_track_thumb_shadow_and_thumb() {
212 let cmds = paint_switch(true, false);
213 assert!(cmds.iter().any(|c| matches!(c, DrawCommand::DrawShadow { .. })),
214 "thumb must cast an elevation shadow");
215 let circles = cmds.iter().filter(|c| matches!(c, DrawCommand::FillCircle { .. })).count();
216 assert!(circles >= 1, "thumb is a filled circle");
217 }
218
219 #[test]
220 #[ignore] fn switch_showcase() {
222 use super::super::app::WidgetApp;
223 use super::super::Column;
224 use crate::EdgeInsets;
225 let out = std::env::var("SWITCH_PNG").unwrap_or_else(|_| "switch_showcase.png".to_string());
226 let panel = |dark: bool| {
227 let col = Column::new().spacing(22.0).padding(EdgeInsets::all(28.0))
228 .child(Switch::new(false))
229 .child(Switch::new(true))
230 .child(Switch::new(false).disabled())
231 .child(Switch::new(true).disabled());
232 let app = WidgetApp::new(120, 220);
233 if dark { app.dark() } else { app.light() }.render_png(&col)
234 };
235 std::fs::write(&out, panel(true)).unwrap();
236 let light = out.replace(".png", "_light.png");
237 std::fs::write(&light, panel(false)).unwrap();
238 println!("wrote {out} and {light}");
239 }
240
241 #[test]
242 fn on_and_off_thumbs_land_at_different_x() {
243 let cx = |on: bool| {
245 paint_switch(on, false).into_iter().find_map(|c| match c {
246 DrawCommand::FillCircle { center, .. } => Some(center.x),
247 _ => None,
248 }).expect("a thumb circle")
249 };
250 assert!(cx(true) > cx(false), "on-thumb must sit right of off-thumb");
251 }
252}