1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_render::Color;
5use super::{Widget, LayoutCtx, PaintCtx, avail_w};
6use super::container::draw_rounded_rect_pub;
7
8pub struct Slider {
22 pub value: f32, pub min: f32,
24 pub max: f32,
25 disabled: bool,
26 height: f32,
27 width: Option<f32>,
28 track_color: Option<Color>,
29 fill_color: Option<Color>,
30 thumb_color: Option<Color>,
31 on_change: Option<Arc<dyn Fn(f32) + Send + Sync>>,
32}
33
34impl Slider {
35 pub fn new(value: f32) -> Self {
36 Self {
37 value: value.clamp(0.0, 1.0),
38 min: 0.0,
39 max: 1.0,
40 disabled: false,
41 height: 24.0,
42 width: None,
43 track_color: None,
44 fill_color: None,
45 thumb_color: None,
46 on_change: None,
47 }
48 }
49 pub fn range(mut self, min: f32, max: f32, value: f32) -> Self {
50 self.min = min; self.max = max;
51 self.value = ((value - min) / (max - min)).clamp(0.0, 1.0);
52 self
53 }
54 pub fn width(mut self, w: f32) -> Self { self.width = Some(w); self }
55 pub fn height(mut self, h: f32) -> Self { self.height = h; self }
56 pub fn disabled(mut self) -> Self { self.disabled = true; self }
57 pub fn disabled_if(mut self, c: bool) -> Self { if c { self.disabled = true; } self }
58 pub fn track_color(mut self, c: Color) -> Self { self.track_color = Some(c); self }
59 pub fn fill_color(mut self, c: Color) -> Self { self.fill_color = Some(c); self }
60 pub fn thumb_color(mut self, c: Color) -> Self { self.thumb_color = Some(c); self }
61
62 pub fn on_change(mut self, f: impl Fn(f32) + Send + Sync + 'static) -> Self {
64 self.on_change = Some(Arc::new(f));
65 self
66 }
67}
68
69fn with_alpha(c: Color, a: f32) -> Color {
70 Color::rgba(c.r, c.g, c.b, (a.clamp(0.0, 1.0) * 255.0).round() as u8)
71}
72
73impl Widget for Slider {
74 fn layout(&self, ctx: &LayoutCtx) -> Size {
75 Size { width: self.width.unwrap_or(avail_w(ctx.constraints)), height: self.height }
76 }
77
78 fn paint(&self, ctx: &mut PaintCtx) {
79 ctx.semantics(super::Semantics::new(rosace_core::Role::Slider)
80 .value(format!("{:.2}", self.min + self.value * (self.max - self.min))));
81
82 let base_r = 9.0;
85 let r = ctx.rect;
86 let usable = (r.size.width - base_r * 2.0).max(1.0);
87 match (&self.on_change, self.disabled) {
88 (Some(f), false) => {
89 let f = f.clone();
90 let (min, max, x0) = (self.min, self.max, r.origin.x + base_r);
91 ctx.on_press_at(move |px, _| {
92 let t = ((px - x0) / usable).clamp(0.0, 1.0);
93 f(min + t * (max - min));
94 });
95 }
96 _ => ctx.on_press_at(|_, _| {}),
97 }
98 let focused = !self.disabled && ctx.focus_node().is_focused();
99 let hovered = !self.disabled && ctx.hovered();
100 let pressed = !self.disabled && ctx.pressed();
101
102 let halo_t = if pressed { 0.18 } else if focused { 0.12 } else if hovered { 0.08 } else { 0.0 };
105 let halo = ctx.animate_channel(0, halo_t, 0.0);
106 let grow = ctx.animate_channel(1, if pressed { 1.0 } else if hovered { 0.5 } else { 0.0 }, 0.0);
107 let thumb_r = base_r + grow * 2.0;
108
109 let colors = ctx.theme.colors.clone();
111 let track = self.track_color.unwrap_or_else(|| ctx.tc(colors.surface_variant));
112 let fill = self.fill_color.unwrap_or_else(|| ctx.tc(colors.primary));
113 let thumb = self.thumb_color.unwrap_or_else(|| Color::rgb(250, 250, 252));
114 let shadow = ctx.tc(colors.shadow);
115 let dim = if self.disabled { 0.4 } else { 1.0 };
116
117 let cy = r.origin.y + r.size.height / 2.0;
118 let track_h = 6.0;
119 let tr = track_h / 2.0;
120 let cx = r.origin.x + base_r + usable * self.value;
121
122 draw_rounded_rect_pub(ctx, Rect {
124 origin: Point { x: r.origin.x, y: cy - tr }, size: Size { width: r.size.width, height: track_h },
125 }, with_alpha(track, dim), tr);
126 let fill_w = cx - r.origin.x;
127 if fill_w > tr {
128 draw_rounded_rect_pub(ctx, Rect {
129 origin: Point { x: r.origin.x, y: cy - tr }, size: Size { width: fill_w, height: track_h },
130 }, with_alpha(fill, dim), tr);
131 }
132
133 if halo > 0.001 {
135 ctx.fill_circle(Point { x: cx, y: cy }, thumb_r + 8.0, with_alpha(fill, halo));
136 }
137
138 let d = thumb_r * 2.0;
140 ctx.fill_shadow_rrect(
141 Rect { origin: Point { x: cx - thumb_r, y: cy - thumb_r + 1.0 }, size: Size { width: d, height: d } },
142 thumb_r, with_alpha(shadow, 0.3 * dim), 4.0,
143 );
144 ctx.fill_circle(Point { x: cx, y: cy }, thumb_r, with_alpha(thumb, dim));
145
146 if focused {
148 ctx.stroke_rrect(
149 Rect { origin: Point { x: cx - thumb_r - 3.0, y: cy - thumb_r - 3.0 }, size: Size { width: d + 6.0, height: d + 6.0 } },
150 thumb_r + 3.0, with_alpha(fill, 0.9), 2.0,
151 );
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use rosace_layout::Constraints;
160 use rosace_render::{FontCache, PictureRecorder};
161 use rosace_render::draw_command::DrawCommand;
162 use std::cell::RefCell;
163 use std::rc::Rc;
164 use crate::tree::RenderTree;
165
166 #[test]
167 fn customization_builders_do_not_change_layout_size() {
168 let font = rosace_render::FontCache::embedded();
169 let theme = rosace_theme::built_in::dark_theme();
170 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
171 let base = Slider::new(0.5);
172 let customized = Slider::new(0.5).height(30.0).fill_color(Color::rgb(255, 0, 0));
173 assert_eq!(base.layout(&ctx).width, customized.layout(&ctx).width);
174 assert_eq!(customized.layout(&ctx).height, 30.0);
175 }
176
177 #[test]
178 #[ignore] fn slider_showcase() {
180 use super::super::app::WidgetApp;
181 use super::super::Column;
182 use crate::EdgeInsets;
183 let out = std::env::var("SLIDER_PNG").unwrap_or_else(|_| "slider_showcase.png".to_string());
184 let panel = |dark: bool| {
185 let col = Column::new().spacing(22.0).padding(EdgeInsets::all(26.0))
186 .child(Slider::new(0.2).width(220.0))
187 .child(Slider::new(0.5).width(220.0))
188 .child(Slider::new(0.85).width(220.0))
189 .child(Slider::new(0.6).width(220.0).disabled());
190 let app = WidgetApp::new(280, 200);
191 if dark { app.dark() } else { app.light() }.render_png(&col)
192 };
193 std::fs::write(&out, panel(true)).unwrap();
194 std::fs::write(out.replace(".png", "_light.png"), panel(false)).unwrap();
195 println!("wrote {out}");
196 }
197
198 fn thumb_x(value: f32) -> f32 {
199 let font = FontCache::embedded();
200 let mut rec = PictureRecorder::new();
201 let tree = Rc::new(RefCell::new(RenderTree::new()));
202 let mut ctx = PaintCtx::root(
203 &mut rec,
204 Rect { origin: Point { x: 0.0, y: 0.0 }, size: Size { width: 200.0, height: 24.0 } },
205 &font, rosace_theme::built_in::dark_theme(), tree,
206 );
207 Slider::new(value).paint(&mut ctx);
208 rec.finish().commands.into_iter().rev().find_map(|c| match c {
210 DrawCommand::FillCircle { center, .. } => Some(center.x),
211 _ => None,
212 }).expect("a thumb circle")
213 }
214
215 #[test]
216 fn thumb_tracks_value_left_to_right() {
217 assert!(thumb_x(0.0) < thumb_x(0.5), "thumb moves right as value grows");
218 assert!(thumb_x(0.5) < thumb_x(1.0), "thumb keeps moving right");
219 }
220
221 #[test]
222 fn thumb_stays_within_the_track() {
223 assert!(thumb_x(0.0) >= 0.0 && thumb_x(1.0) <= 200.0, "thumb never overflows the track");
224 }
225}