Skip to main content

rosace_widgets/tree/
stepper.rs

1//! `Stepper` (D115/Phase 32 Step 1) — the numeric −/+ control:
2//! `[−] value [+]` with Button-style hover/press feedback per segment.
3//!
4//! Controlled, like every input widget: the app owns the value — pass it
5//! to [`Stepper::new`] and write the new value back in `.on_change`.
6//! At an end stop the corresponding button dims and absorbs the click
7//! without firing (interactive-by-identity: it still owns its hit region).
8
9use 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
18/// Minimum width of the central value segment (logical px).
19const MIN_VALUE_WIDTH: f32 = 48.0;
20
21/// Clamped stepper arithmetic: `cur + delta × step`, saturating on
22/// overflow, clamped into `[min, max]` (normalized if given reversed).
23/// `delta` is the direction: `-1` for the − button, `+1` for the +.
24pub(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
29/// A numeric stepper: decrement button, current value, increment button.
30pub struct Stepper {
31    value: i64,
32    min: i64,
33    max: i64,
34    step: i64,
35    height: f32,
36    /// `None` = read from the active theme's `typography.body_medium`
37    /// (D127 "environment" track — see `Checkbox::resolved_font_size`'s doc
38    /// for the reasoning).
39    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    /// A stepper showing `value` (unbounded range, step 1 by default).
49    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    /// Lower bound (inclusive). The − button dims and stops firing there.
65    pub fn min(mut self, v: i64) -> Self { self.min = v; self }
66    /// Upper bound (inclusive). The + button dims and stops firing there.
67    pub fn max(mut self, v: i64) -> Self { self.max = v; self }
68    /// Increment per press (default `1`; clamped to at least `1`).
69    pub fn step(mut self, s: i64) -> Self { self.step = s.max(1); self }
70    /// Control height in logical px (default `32.0`).
71    pub fn height(mut self, h: f32) -> Self { self.height = h.max(0.0); self }
72    /// Value/glyph text size (default: theme `typography.body_medium`).
73    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    /// Corner radius of the track (default `6.0`).
79    pub fn radius(mut self, r: f32) -> Self { self.radius = r.max(0.0); self }
80    /// Track fill — defaults to the theme's `surface_variant`.
81    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
82    /// Text/glyph tint — defaults to the theme's `on_surface`.
83    pub fn color(mut self, c: Color) -> Self { self.foreground = Some(c); self }
84    /// Outline stroke around the track (color, width).
85    pub fn border(mut self, c: Color, w: f32) -> Self { self.border = Some((c, w)); self }
86    /// Called with the new (already clamped) value on every effective press.
87    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    /// Width of one −/+ button segment: square, matching the height.
93    fn button_width(&self) -> f32 { self.height }
94
95    /// Width of the central value segment for the current value's text.
96    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        // Hoisted theme reads (the borrow must end before mutable painting).
111        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        // The two button segments + the centered value.
138        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            // Hover/press lift, the Button/FAB convention (D108 Step 1).
165            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            // Interactive-by-identity: the segment ALWAYS owns its hit
190            // region — disabled/unwired presses absorb and do nothing.
191            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        // 2 square buttons (32 each) + the value segment (≥ MIN_VALUE_WIDTH).
220        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        // Clamps at both ends (including a partial last step).
229        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); // min/max swapped by caller
244    }
245}