use std::sync::Arc;
use rosace_core::types::{Point, Rect, Size};
use rosace_render::Color;
use super::{Widget, LayoutCtx, PaintCtx};
use super::container::draw_rounded_rect_pub;
pub struct Switch {
pub on: bool,
disabled: bool,
label: Option<String>,
width: f32,
height: f32,
on_change: Option<Arc<dyn Fn(bool) + Send + Sync>>,
on_color: Option<Color>,
off_color: Option<Color>,
thumb_color: Option<Color>,
}
impl Switch {
pub fn new(on: bool) -> Self {
Self {
on,
disabled: false,
label: None,
width: 44.0,
height: 24.0,
on_change: None,
on_color: None,
off_color: None,
thumb_color: None,
}
}
pub fn on_change(mut self, f: impl Fn(bool) + Send + Sync + 'static) -> Self {
self.on_change = Some(Arc::new(f));
self
}
pub fn disabled(mut self) -> Self { self.disabled = true; self }
pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
pub fn label(mut self, l: impl Into<String>) -> Self { self.label = Some(l.into()); self }
pub fn size(mut self, width: f32, height: f32) -> Self {
self.width = width;
self.height = height;
self
}
pub fn on_color(mut self, c: Color) -> Self { self.on_color = Some(c); self }
pub fn off_color(mut self, c: Color) -> Self { self.off_color = Some(c); self }
pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
}
fn with_alpha(c: Color, a: f32) -> Color {
Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
}
impl Widget for Switch {
fn layout(&self, ctx: &LayoutCtx) -> Size {
ctx.constraints.constrain(Size { width: self.width, height: self.height })
}
fn paint(&self, ctx: &mut PaintCtx) {
let mut sem = super::Semantics::new(rosace_core::Role::Switch)
.value(if self.on { "on" } else { "off" });
if let Some(l) = &self.label { sem = sem.label(l); }
ctx.semantics(sem);
let toggle: Arc<dyn Fn() + Send + Sync> = match (&self.on_change, self.disabled) {
(Some(f), false) => { let f = f.clone(); let next = !self.on; Arc::new(move || f(next)) }
_ => Arc::new(|| {}),
};
ctx.register_hit(toggle);
let focused = !self.disabled && ctx.focus_node().is_focused();
let hovered = !self.disabled && ctx.hovered();
let pressed = !self.disabled && ctx.pressed();
let t = ctx.animate_channel(0, if self.on { 1.0 } else { 0.0 }, 0.0);
let halo_target = if pressed { 0.16 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
let halo = ctx.animate_channel(1, halo_target, 0.0);
let press_amt = ctx.animate_channel(2, if pressed { 1.0 } else if hovered { 0.35 } else { 0.0 }, 0.0);
let colors = &ctx.theme.colors;
let on_track = self.on_color.unwrap_or_else(|| ctx.tc(colors.primary));
let off_track = self.off_color.unwrap_or_else(|| ctx.tc(colors.surface_variant));
let thumb_c = self.thumb_color.unwrap_or_else(|| Color::rgb(250, 250, 252));
let outline = ctx.tc(colors.outline);
let shadow = ctx.tc(colors.shadow);
let dim = if self.disabled { 0.38 } else { 1.0 };
let r = ctx.rect;
let radius = r.size.height / 2.0;
let track = super::lerp_color(off_track, on_track, t);
draw_rounded_rect_pub(ctx, r, with_alpha(track, dim), radius);
if t < 1.0 {
ctx.stroke_rrect(r, radius, with_alpha(outline, (1.0 - t) * dim), 1.0);
}
let pad = 2.0;
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;
let off_cx = r.origin.x + pad + base_r;
let on_cx = r.origin.x + r.size.width - pad - base_r;
let cx = off_cx + (on_cx - off_cx) * t;
if halo > 0.001 {
let halo_color = super::lerp_color(outline, on_track, t);
ctx.fill_circle(Point { x: cx, y: cy }, thumb_r + 8.0, with_alpha(halo_color, halo));
}
let d = thumb_r * 2.0;
ctx.fill_shadow_rrect(
Rect { origin: Point { x: cx - thumb_r, y: cy - thumb_r + 1.0 }, size: Size { width: d, height: d } },
thumb_r,
with_alpha(shadow, 0.28 * dim),
4.0,
);
ctx.fill_circle(Point { x: cx, y: cy }, thumb_r, with_alpha(thumb_c, dim));
}
}
#[cfg(test)]
mod tests {
use super::*;
use rosace_render::{FontCache, PictureRecorder};
use rosace_render::draw_command::DrawCommand;
use std::cell::RefCell;
use std::rc::Rc;
use crate::tree::RenderTree;
fn paint_switch(on: bool, disabled: bool) -> Vec<DrawCommand> {
let font = FontCache::embedded();
let mut rec = PictureRecorder::new();
let tree = Rc::new(RefCell::new(RenderTree::new()));
let mut ctx = PaintCtx::root(
&mut rec,
Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 44.0, height: 24.0 } },
&font,
rosace_theme::built_in::dark_theme(),
tree,
);
let mut s = Switch::new(on);
if disabled { s = s.disabled(); }
s.paint(&mut ctx);
rec.finish().commands
}
#[test]
fn default_size_is_the_premium_track() {
let font = FontCache::embedded();
let theme = rosace_theme::built_in::dark_theme();
let ctx = LayoutCtx::new(rosace_layout::Constraints::loose(200.0, 200.0), &font, &theme);
assert_eq!(Switch::new(false).layout(&ctx), Size { width: 44.0, height: 24.0 });
}
#[test]
fn paints_track_thumb_shadow_and_thumb() {
let cmds = paint_switch(true, false);
assert!(cmds.iter().any(|c| matches!(c, DrawCommand::DrawShadow { .. })),
"thumb must cast an elevation shadow");
let circles = cmds.iter().filter(|c| matches!(c, DrawCommand::FillCircle { .. })).count();
assert!(circles >= 1, "thumb is a filled circle");
}
#[test]
#[ignore] fn switch_showcase() {
use super::super::app::WidgetApp;
use super::super::Column;
use crate::EdgeInsets;
let out = std::env::var("SWITCH_PNG").unwrap_or_else(|_| "switch_showcase.png".to_string());
let panel = |dark: bool| {
let col = Column::new().spacing(22.0).padding(EdgeInsets::all(28.0))
.child(Switch::new(false))
.child(Switch::new(true))
.child(Switch::new(false).disabled())
.child(Switch::new(true).disabled());
let app = WidgetApp::new(120, 220);
if dark { app.dark() } else { app.light() }.render_png(&col)
};
std::fs::write(&out, panel(true)).unwrap();
let light = out.replace(".png", "_light.png");
std::fs::write(&light, panel(false)).unwrap();
println!("wrote {out} and {light}");
}
#[test]
fn on_and_off_thumbs_land_at_different_x() {
let cx = |on: bool| {
paint_switch(on, false).into_iter().find_map(|c| match c {
DrawCommand::FillCircle { center, .. } => Some(center.x),
_ => None,
}).expect("a thumb circle")
};
assert!(cx(true) > cx(false), "on-thumb must sit right of off-thumb");
}
}