1use std::sync::Arc;
10
11use rosace_core::types::{Point, Rect, Size};
12use rosace_render::Color;
13
14use super::button::lighten;
15use super::container::draw_rounded_rect_pub;
16use super::{LayoutCtx, PaintCtx, Widget};
17
18const MIN_VALUE_WIDTH: f32 = 48.0;
20
21pub(crate) fn next_value(cur: i64, delta: i64, min: i64, max: i64, step: i64) -> i64 {
25 let (lo, hi) = if min <= max { (min, max) } else { (max, min) };
26 cur.saturating_add(delta.saturating_mul(step)).clamp(lo, hi)
27}
28
29pub struct Stepper {
31 value: i64,
32 min: i64,
33 max: i64,
34 step: i64,
35 height: f32,
36 font_size: Option<f32>,
40 radius: f32,
41 background: Option<Color>,
42 foreground: Option<Color>,
43 border: Option<(Color, f32)>,
44 on_change: Option<Arc<dyn Fn(i64) + Send + Sync>>,
45}
46
47impl Stepper {
48 pub fn new(value: i64) -> Self {
50 Self {
51 value,
52 min: i64::MIN,
53 max: i64::MAX,
54 step: 1,
55 height: 32.0,
56 font_size: None,
57 radius: 6.0,
58 background: None,
59 foreground: None,
60 border: None,
61 on_change: None,
62 }
63 }
64 pub fn min(mut self, v: i64) -> Self { self.min = v; self }
66 pub fn max(mut self, v: i64) -> Self { self.max = v; self }
68 pub fn step(mut self, s: i64) -> Self { self.step = s.max(1); self }
70 pub fn height(mut self, h: f32) -> Self { self.height = h.max(0.0); self }
72 pub fn font_size(mut self, s: f32) -> Self { self.font_size = Some(s); self }
74
75 fn resolved_font_size(&self, theme: &rosace_theme::ThemeData) -> f32 {
76 self.font_size.unwrap_or(theme.typography.body_medium.size)
77 }
78 pub fn radius(mut self, r: f32) -> Self { self.radius = r.max(0.0); self }
80 pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
82 pub fn color(mut self, c: Color) -> Self { self.foreground = Some(c); self }
84 pub fn border(mut self, c: Color, w: f32) -> Self { self.border = Some((c, w)); self }
86 pub fn on_change(mut self, f: impl Fn(i64) + Send + Sync + 'static) -> Self {
88 self.on_change = Some(Arc::new(f));
89 self
90 }
91
92 fn button_width(&self) -> f32 { self.height }
94
95 fn value_width(&self, ctx_font: &rosace_render::FontCache, font_size: f32) -> f32 {
97 (ctx_font.measure_text(&self.value.to_string(), font_size) + 16.0)
98 .max(MIN_VALUE_WIDTH)
99 }
100}
101
102impl Widget for Stepper {
103 fn layout(&self, ctx: &LayoutCtx) -> Size {
104 let font_size = self.resolved_font_size(ctx.theme);
105 let w = self.button_width() * 2.0 + self.value_width(ctx.font, font_size);
106 ctx.constraints.constrain(Size { width: w, height: self.height })
107 }
108
109 fn paint(&self, ctx: &mut PaintCtx) {
110 let (bg, fg) = {
112 let t = &ctx.theme.colors;
113 (
114 self.background.unwrap_or_else(|| ctx.tc(t.surface_variant)),
115 self.foreground.unwrap_or_else(|| ctx.tc(t.on_surface)),
116 )
117 };
118 let dim = Color::rgba(fg.r, fg.g, fg.b, 90);
119 let font_size = self.resolved_font_size(&ctx.theme);
120
121 let r = ctx.rect;
122 ctx.semantics(
123 super::Semantics::new(rosace_core::Role::Slider)
124 .label("stepper")
125 .value(self.value.to_string()),
126 );
127
128 draw_rounded_rect_pub(ctx, r, bg, self.radius);
129 if let Some((bc, bw)) = self.border {
130 ctx.stroke_rrect(r, self.radius, bc, bw);
131 }
132
133 let bw = self.button_width().min(r.size.width / 2.0);
134 let at_min = self.value <= self.min;
135 let at_max = self.value >= self.max;
136
137 let minus_rect = Rect { origin: r.origin, size: Size { width: bw, height: r.size.height } };
139 let plus_rect = Rect {
140 origin: Point { x: r.origin.x + r.size.width - bw, y: r.origin.y },
141 size: Size { width: bw, height: r.size.height },
142 };
143
144 let value_text = self.value.to_string();
145 let tw = ctx.font.measure_text(&value_text, font_size);
146 let lh = ctx.font.line_height(font_size);
147 ctx.draw_text_at(
148 &value_text,
149 Point {
150 x: r.origin.x + (r.size.width - tw) / 2.0,
151 y: r.origin.y + (r.size.height - lh) / 2.0,
152 },
153 fg,
154 font_size,
155 );
156
157 for (rect, glyph, disabled, delta, sem_label) in [
158 (minus_rect, "\u{2212}", at_min, -1i64, "decrement"),
159 (plus_rect, "+", at_max, 1i64, "increment"),
160 ] {
161 let mut slot = ctx.child(rect);
162 slot.semantics(super::Semantics::new(rosace_core::Role::Button).label(sem_label));
163
164 let target = if disabled { 0.0 } else if slot.pressed() { 1.0 } else if slot.hovered() { 0.5 } else { 0.0 };
166 let emphasis = slot.animate_to(target, 0.0);
167 if emphasis > 0.0 {
168 draw_rounded_rect_pub(
169 &mut slot,
170 rect,
171 lighten(bg, (0.12 * emphasis * 2.0).min(1.0)),
172 self.radius,
173 );
174 }
175
176 let glyph_color = if disabled { dim } else { fg };
177 let gw = slot.font.measure_text(glyph, font_size);
178 let gh = slot.font.line_height(font_size);
179 slot.draw_text_at(
180 glyph,
181 Point {
182 x: rect.origin.x + (rect.size.width - gw) / 2.0,
183 y: rect.origin.y + (rect.size.height - gh) / 2.0,
184 },
185 glyph_color,
186 font_size,
187 );
188
189 match (&self.on_change, disabled) {
192 (Some(cb), false) => {
193 let cb = Arc::clone(cb);
194 let (cur, min, max, step) = (self.value, self.min, self.max, self.step);
195 slot.register_hit(Arc::new(move || cb(next_value(cur, delta, min, max, step))));
196 }
197 _ => slot.register_hit(Arc::new(|| {})),
198 }
199 }
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use rosace_layout::Constraints;
207
208 fn test_env() -> (rosace_render::FontCache, rosace_theme::ThemeData) {
209 (rosace_render::FontCache::embedded(), rosace_theme::built_in::dark_theme())
210 }
211
212 #[test]
213 fn stepper_is_two_buttons_plus_the_value_segment_wide() {
214 let s = Stepper::new(5).height(32.0);
215 let (font, theme) = test_env();
216 let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
217 let size = s.layout(&ctx);
218 assert_eq!(size.height, 32.0);
219 assert!(size.width >= 64.0 + MIN_VALUE_WIDTH, "got {}", size.width);
221 }
222
223 #[test]
224 fn next_value_steps_and_clamps() {
225 assert_eq!(next_value(5, 1, 0, 10, 1), 6);
226 assert_eq!(next_value(5, -1, 0, 10, 1), 4);
227 assert_eq!(next_value(5, 1, 0, 10, 3), 8);
228 assert_eq!(next_value(10, 1, 0, 10, 1), 10);
230 assert_eq!(next_value(0, -1, 0, 10, 1), 0);
231 assert_eq!(next_value(9, 1, 0, 10, 5), 10);
232 }
233
234 #[test]
235 fn next_value_saturates_instead_of_overflowing() {
236 assert_eq!(next_value(i64::MAX, 1, i64::MIN, i64::MAX, 1), i64::MAX);
237 assert_eq!(next_value(i64::MIN, -1, i64::MIN, i64::MAX, 1), i64::MIN);
238 assert_eq!(next_value(0, 1, i64::MIN, i64::MAX, i64::MAX), i64::MAX);
239 }
240
241 #[test]
242 fn next_value_normalizes_a_reversed_range() {
243 assert_eq!(next_value(5, 1, 10, 0, 1), 6); }
245}