Skip to main content

rlvgl_widgets/
spinbox.rs

1//! Numeric text spinbox with range, step, digit format, and rollover (LPAR-12c).
2//!
3//! [`Spinbox`] stores an integer value with a configurable digit count, decimal
4//! separator position, step size, and range.  The value is formatted as a
5//! right-aligned string with leading zeros, an optional sign column, and an
6//! optional decimal separator; callers interpret decimal placement.
7//!
8//! # Key helpers
9//!
10//! Wire [`rlvgl_core::event::Event::KeyDown`] in the containing node handler:
11//!
12//! * `ArrowUp` → [`increment`](Spinbox::increment)
13//! * `ArrowDown` → [`decrement`](Spinbox::decrement)
14//! * `ArrowRight` → [`on_arrow_right`](Spinbox::on_arrow_right)
15//! * `ArrowLeft` → [`on_arrow_left`](Spinbox::on_arrow_left)
16
17use alloc::string::String;
18use core::fmt::Write as FmtWrite;
19use rlvgl_core::draw::draw_widget_bg;
20use rlvgl_core::event::Event;
21use rlvgl_core::font::{FontMetrics, WidgetFont, shape_text_ltr};
22use rlvgl_core::renderer::{ClipRenderer, Renderer};
23use rlvgl_core::style::Style;
24use rlvgl_core::widget::{Color, Rect, Widget};
25
26/// Direction of cursor movement when stepping through digits.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SpinboxDigitStepDirection {
29    /// Move cursor to the right (toward less significant digits) when stepping.
30    Right,
31    /// Move cursor to the left (toward more significant digits) when stepping.
32    Left,
33}
34
35/// Numeric text-editing spinbox.
36///
37/// Stores an integer value with a configurable digit count, decimal separator
38/// position, step size, and range.  The value is formatted as a string with
39/// leading zeros and an optional sign column; callers interpret decimal placement.
40///
41/// # Defaults
42///
43/// * `value`: 0
44/// * `digit_count`: 5
45/// * `separator_position`: 0 (hidden)
46/// * `step`: 1
47/// * range: `−99999..=99999`
48/// * `rollover`: disabled
49/// * `digit_step_direction`: right
50pub struct Spinbox {
51    bounds: Rect,
52    value: i32,
53    min: i32,
54    max: i32,
55    rollover: bool,
56    digit_count: u8,
57    separator_position: u8,
58    step: i32,
59    digit_step_direction: SpinboxDigitStepDirection,
60    /// Visual style for the spinbox background.
61    pub style: Style,
62    /// Color used to render the numeric text.
63    pub text_color: Color,
64    /// Color used for the selected-digit cursor underline.
65    pub cursor_color: Color,
66    /// Font assignment for this widget (FONT-00 §5); resolves to `FONT_6X10`
67    /// when unset.
68    font: WidgetFont,
69}
70
71impl Spinbox {
72    /// Create a spinbox with default LVGL-parity settings occupying `bounds`.
73    pub fn new(bounds: Rect) -> Self {
74        Self {
75            bounds,
76            value: 0,
77            min: -99_999,
78            max: 99_999,
79            rollover: false,
80            digit_count: 5,
81            separator_position: 0,
82            step: 1,
83            digit_step_direction: SpinboxDigitStepDirection::Right,
84            style: Style::default(),
85            text_color: Color(20, 20, 20, 255),
86            cursor_color: Color(0, 120, 215, 255),
87            font: WidgetFont::new(),
88        }
89    }
90
91    /// Set the current value, clamping to the configured range.
92    pub fn set_value(&mut self, value: i32) {
93        self.value = value.clamp(self.min, self.max);
94    }
95
96    /// Return the current value.
97    pub fn value(&self) -> i32 {
98        self.value
99    }
100
101    /// Set the inclusive value range and clamp the current value.
102    pub fn set_range(&mut self, min: i32, max: i32) {
103        self.min = min;
104        self.max = max;
105        self.value = self.value.clamp(self.min, self.max);
106    }
107
108    /// Return the configured minimum value.
109    pub fn min_value(&self) -> i32 {
110        self.min
111    }
112
113    /// Return the configured maximum value.
114    pub fn max_value(&self) -> i32 {
115        self.max
116    }
117
118    /// Enable or disable rollover at range boundaries.
119    pub fn set_rollover(&mut self, rollover: bool) {
120        self.rollover = rollover;
121    }
122
123    /// Return `true` when rollover is enabled.
124    pub fn rollover(&self) -> bool {
125        self.rollover
126    }
127
128    /// Configure the digit count and decimal separator position.
129    ///
130    /// `digit_count` is clamped to `1..=10`; it excludes the sign and
131    /// separator character.  `separator_position == 0` or
132    /// `separator_position >= digit_count` hides the separator.  Setting the
133    /// format tightens the representable range to
134    /// `−(10^digit_count − 1)..=(10^digit_count − 1)` and clamps the value.
135    pub fn set_digit_format(&mut self, digit_count: u8, separator_position: u8) {
136        self.digit_count = digit_count.clamp(1, 10);
137        self.separator_position = separator_position;
138        let max_repr = max_for_digits(self.digit_count);
139        self.min = self.min.clamp(-max_repr, max_repr);
140        self.max = self.max.clamp(-max_repr, max_repr);
141        self.value = self.value.clamp(self.min, self.max);
142    }
143
144    /// Return the configured digit count (excluding sign and separator).
145    pub fn digit_count(&self) -> u8 {
146        self.digit_count
147    }
148
149    /// Return the separator position (0 = hidden).
150    pub fn separator_position(&self) -> u8 {
151        self.separator_position
152    }
153
154    /// Set the step size for increment/decrement operations (minimum 1).
155    pub fn set_step(&mut self, step: i32) {
156        self.step = step.max(1);
157    }
158
159    /// Return the current step size.
160    pub fn step(&self) -> i32 {
161        self.step
162    }
163
164    /// Set the direction for digit-step cursor movement.
165    pub fn set_digit_step_direction(&mut self, dir: SpinboxDigitStepDirection) {
166        self.digit_step_direction = dir;
167    }
168
169    /// Return the digit-step direction.
170    pub fn digit_step_direction(&self) -> SpinboxDigitStepDirection {
171        self.digit_step_direction
172    }
173
174    /// Move the selected digit toward more significant digits.
175    ///
176    /// Multiplies the step by 10, capped at `10^(digit_count − 1)`.
177    pub fn step_next(&mut self) {
178        let cap = max_step(self.digit_count);
179        if self.step < cap {
180            self.step = (self.step.saturating_mul(10)).min(cap);
181        }
182    }
183
184    /// Move the selected digit toward less significant digits.
185    ///
186    /// Divides the step by 10, floored at `1`.
187    pub fn step_prev(&mut self) {
188        if self.step > 1 {
189            self.step /= 10;
190        }
191    }
192
193    /// Increment the value by the current step, respecting range and rollover.
194    ///
195    /// LVGL zero-crossing behavior: when the step carries the value from
196    /// negative to positive the value lands on `0` first.
197    pub fn increment(&mut self) {
198        let v = self.value;
199        let s = self.step;
200        let new = if v < 0 && v.saturating_add(s) > 0 {
201            0
202        } else {
203            v.saturating_add(s)
204        };
205        if new > self.max {
206            self.value = if self.rollover { self.min } else { self.max };
207        } else {
208            self.value = new;
209        }
210    }
211
212    /// Decrement the value by the current step, respecting range and rollover.
213    ///
214    /// LVGL zero-crossing behavior: when the step carries the value from
215    /// positive to negative the value lands on `0` first.
216    pub fn decrement(&mut self) {
217        let v = self.value;
218        let s = self.step;
219        let new = if v > 0 && v.saturating_sub(s) < 0 {
220            0
221        } else {
222            v.saturating_sub(s)
223        };
224        if new < self.min {
225            self.value = if self.rollover { self.max } else { self.min };
226        } else {
227            self.value = new;
228        }
229    }
230
231    /// Increment via `ArrowUp` key — convenience wrapper for node handlers.
232    pub fn on_arrow_up(&mut self) {
233        self.increment();
234    }
235
236    /// Decrement via `ArrowDown` key — convenience wrapper.
237    pub fn on_arrow_down(&mut self) {
238        self.decrement();
239    }
240
241    /// Move cursor right (toward less significant digit) via `ArrowRight` key.
242    pub fn on_arrow_right(&mut self) {
243        match self.digit_step_direction {
244            SpinboxDigitStepDirection::Right => self.step_prev(),
245            SpinboxDigitStepDirection::Left => self.step_next(),
246        }
247    }
248
249    /// Move cursor left (toward more significant digit) via `ArrowLeft` key.
250    pub fn on_arrow_left(&mut self) {
251        match self.digit_step_direction {
252            SpinboxDigitStepDirection::Right => self.step_next(),
253            SpinboxDigitStepDirection::Left => self.step_prev(),
254        }
255    }
256
257    /// Return the formatted numeric string.
258    ///
259    /// Format: optional sign column, `digit_count` digits with leading zeros,
260    /// and an optional decimal separator inserted `separator_position` places
261    /// from the right.  Negative-capable ranges (`min < 0`) prefix `−` for
262    /// negative values and `+` for positives; positive-only ranges omit the
263    /// sign column entirely.
264    pub fn text(&self) -> String {
265        let dc = self.digit_count as usize;
266        let sep = self.separator_position as usize;
267        let show_sign = self.min < 0;
268        let abs_val = self.value.unsigned_abs() as u64;
269
270        // Build digit characters (most-significant first)
271        let mut digits = [b'0'; 10];
272        let mut temp = abs_val;
273        for i in (0..dc).rev() {
274            digits[i] = b'0' + (temp % 10) as u8;
275            temp /= 10;
276        }
277
278        let mut s = String::new();
279
280        if show_sign {
281            let sign = if self.value < 0 { '-' } else { '+' };
282            s.push(sign);
283        }
284
285        let sep_valid = sep > 0 && sep < dc;
286        let sep_insert_before = dc - sep; // insert '.' before digit at this index from start
287
288        for (i, &digit) in digits[..dc].iter().enumerate() {
289            if sep_valid && i == sep_insert_before {
290                s.push('.');
291            }
292            s.push(digit as char);
293        }
294
295        s
296    }
297
298    /// Assign the font used to render this widget (FONT-00 §5); resolves to
299    /// `FONT_6X10` when unset.
300    pub fn set_font(&mut self, font: &'static dyn FontMetrics) {
301        self.font.set(font);
302    }
303
304    /// Return the character index in [`text()`](Self::text) for the digit
305    /// selected by the current step.
306    ///
307    /// Used internally by [`draw`](Widget::draw) to position the cursor underline.
308    fn selected_char_index(&self) -> usize {
309        let dc = self.digit_count as usize;
310        let sep = self.separator_position as usize;
311        let show_sign = self.min < 0;
312
313        // Which digit from the right is selected?
314        let digit_from_right = {
315            let mut d = 0usize;
316            let mut s = self.step;
317            while s > 1 {
318                s /= 10;
319                d += 1;
320            }
321            d
322        };
323        // Convert to index from left in the digit array (0 = most significant)
324        let digit_from_left = dc.saturating_sub(1).saturating_sub(digit_from_right);
325
326        // Account for sign prefix and separator character
327        let sep_valid = sep > 0 && sep < dc;
328        let sep_insert_before = dc - sep;
329        let sep_before = if sep_valid && digit_from_left >= sep_insert_before {
330            1
331        } else {
332            0
333        };
334        let sign_offset = if show_sign { 1 } else { 0 };
335
336        sign_offset + digit_from_left + sep_before
337    }
338}
339
340fn max_for_digits(digit_count: u8) -> i32 {
341    let n = digit_count.min(9) as u32; // i32::MAX ~ 2.1e9; 10^9 fits
342    let mut v: i32 = 1;
343    for _ in 0..n {
344        v = v.saturating_mul(10);
345    }
346    v.saturating_sub(1)
347}
348
349fn max_step(digit_count: u8) -> i32 {
350    // max step = 10^(digit_count - 1)
351    let n = digit_count.saturating_sub(1).min(9) as u32;
352    let mut v: i32 = 1;
353    for _ in 0..n {
354        v = v.saturating_mul(10);
355    }
356    v
357}
358
359impl Widget for Spinbox {
360    fn bounds(&self) -> Rect {
361        self.bounds
362    }
363
364    fn widget_font_mut(&mut self) -> Option<&mut WidgetFont> {
365        Some(&mut self.font)
366    }
367
368    fn set_bounds(&mut self, bounds: Rect) {
369        self.bounds = bounds;
370    }
371
372    fn draw(&self, renderer: &mut dyn Renderer) {
373        draw_widget_bg(renderer, self.bounds, &self.style);
374
375        let text = self.text();
376        let font = self.font.resolve();
377        let metrics = font.line_metrics();
378        let baseline = self.bounds.y + metrics.ascent as i32;
379        let shaped = shape_text_ltr(font, &text, (self.bounds.x, baseline), 0);
380        let color = self.text_color.with_alpha(self.style.alpha);
381
382        {
383            let mut clipped = ClipRenderer::new(renderer, self.bounds);
384            clipped.draw_text_shaped(&shaped, (0, 0), color);
385        }
386
387        // Draw cursor underline under the selected digit
388        let char_idx = self.selected_char_index();
389        if let Some(glyph) = shaped.glyphs.get(char_idx) {
390            let ext = glyph.extent();
391            let cursor_rect = Rect {
392                x: ext.x,
393                y: ext.y + ext.height,
394                width: ext.width.max(1),
395                height: 2,
396            };
397            renderer.fill_rect(cursor_rect, self.cursor_color.with_alpha(self.style.alpha));
398        }
399    }
400
401    fn handle_event(&mut self, _event: &Event) -> bool {
402        // Key navigation is wired by the app via ObjectEvent::Key node handlers;
403        // raw key events are not handled here (LPAR-12 §5.E).
404        false
405    }
406}
407
408// Suppress unused-import warning for FmtWrite which is only used implicitly
409// through the String::write_fmt call generated by format! / write! below.
410// We use manual digit assembly so FmtWrite is not actually referenced directly;
411// keep the import to document intent and suppress the lint in that scenario.
412// Actually we don't use write! here — remove it.
413#[allow(unused_imports)]
414use FmtWrite as _;
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
421        Rect {
422            x,
423            y,
424            width: w,
425            height: h,
426        }
427    }
428
429    #[test]
430    fn default_value_and_range() {
431        let sb = Spinbox::new(rect(0, 0, 80, 20));
432        assert_eq!(sb.value(), 0);
433        assert_eq!(sb.min_value(), -99_999);
434        assert_eq!(sb.max_value(), 99_999);
435        assert_eq!(sb.digit_count(), 5);
436        assert_eq!(sb.separator_position(), 0);
437        assert_eq!(sb.step(), 1);
438        assert!(!sb.rollover());
439    }
440
441    #[test]
442    fn set_value_clamps_to_range() {
443        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
444        sb.set_value(200_000);
445        assert_eq!(sb.value(), 99_999);
446        sb.set_value(-200_000);
447        assert_eq!(sb.value(), -99_999);
448    }
449
450    #[test]
451    fn set_range_clamps_value() {
452        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
453        sb.set_value(50);
454        sb.set_range(0, 30);
455        assert_eq!(sb.value(), 30);
456    }
457
458    #[test]
459    fn digit_format_shows_plus_and_leading_zeros() {
460        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
461        sb.set_value(42);
462        let t = sb.text();
463        assert!(t.starts_with('+'), "expected '+' prefix, got: {t}");
464        assert!(t.contains("00042"), "expected leading zeros in: {t}");
465    }
466
467    #[test]
468    fn positive_only_range_omits_sign() {
469        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
470        sb.set_range(0, 99_999);
471        sb.set_value(5);
472        let t = sb.text();
473        assert!(
474            !t.starts_with('+') && !t.starts_with('-'),
475            "unexpected sign prefix in: {t}"
476        );
477        assert!(t.contains("00005"), "expected leading zeros in: {t}");
478    }
479
480    #[test]
481    fn negative_value_shows_minus() {
482        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
483        sb.set_value(-123);
484        let t = sb.text();
485        assert!(t.starts_with('-'), "expected '-' prefix, got: {t}");
486    }
487
488    #[test]
489    fn separator_position_inserts_dot() {
490        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
491        sb.set_digit_format(5, 2); // dot 2 from the right: "NNN.NN"
492        sb.set_value(12345);
493        let t = sb.text();
494        assert!(t.contains('.'), "expected '.' separator in: {t}");
495    }
496
497    #[test]
498    fn separator_zero_hides_dot() {
499        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
500        sb.set_digit_format(5, 0);
501        sb.set_value(123);
502        assert!(!sb.text().contains('.'), "unexpected '.' in: {}", sb.text());
503    }
504
505    #[test]
506    fn increment_decrement_basic() {
507        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
508        sb.set_value(10);
509        sb.increment();
510        assert_eq!(sb.value(), 11);
511        sb.decrement();
512        sb.decrement();
513        assert_eq!(sb.value(), 9);
514    }
515
516    #[test]
517    fn zero_crossing_increment_lands_on_zero() {
518        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
519        sb.set_step(5);
520        sb.set_value(-3);
521        sb.increment(); // -3 + 5 = 2 > 0  → land at 0
522        assert_eq!(sb.value(), 0);
523    }
524
525    #[test]
526    fn zero_crossing_decrement_lands_on_zero() {
527        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
528        sb.set_step(5);
529        sb.set_value(3);
530        sb.decrement(); // 3 - 5 = -2 < 0  → land at 0
531        assert_eq!(sb.value(), 0);
532    }
533
534    #[test]
535    fn rollover_wraps_at_max() {
536        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
537        sb.set_range(0, 10);
538        sb.set_rollover(true);
539        sb.set_value(10);
540        sb.increment();
541        assert_eq!(sb.value(), 0);
542    }
543
544    #[test]
545    fn rollover_wraps_at_min() {
546        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
547        sb.set_range(0, 10);
548        sb.set_rollover(true);
549        sb.set_value(0);
550        sb.decrement();
551        assert_eq!(sb.value(), 10);
552    }
553
554    #[test]
555    fn no_rollover_clamps_at_max() {
556        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
557        sb.set_range(0, 10);
558        sb.set_value(10);
559        sb.increment();
560        assert_eq!(sb.value(), 10);
561    }
562
563    #[test]
564    fn step_next_multiplies_by_ten() {
565        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
566        assert_eq!(sb.step(), 1);
567        sb.step_next();
568        assert_eq!(sb.step(), 10);
569        sb.step_next();
570        assert_eq!(sb.step(), 100);
571    }
572
573    #[test]
574    fn step_prev_divides_by_ten() {
575        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
576        sb.set_step(100);
577        sb.step_prev();
578        assert_eq!(sb.step(), 10);
579        sb.step_prev();
580        assert_eq!(sb.step(), 1);
581        sb.step_prev(); // floor at 1
582        assert_eq!(sb.step(), 1);
583    }
584
585    #[test]
586    fn step_next_capped_at_digit_count_max() {
587        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
588        sb.set_digit_format(3, 0); // max step = 100
589        for _ in 0..10 {
590            sb.step_next();
591        }
592        assert_eq!(sb.step(), 100);
593    }
594
595    #[test]
596    fn set_bounds_adopted() {
597        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
598        sb.set_bounds(rect(5, 5, 100, 30));
599        assert_eq!(sb.bounds(), rect(5, 5, 100, 30));
600    }
601
602    #[test]
603    fn digit_format_clamps_digit_count() {
604        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
605        sb.set_digit_format(20, 0);
606        assert_eq!(sb.digit_count(), 10);
607    }
608
609    #[test]
610    fn handle_event_always_returns_false() {
611        let mut sb = Spinbox::new(rect(0, 0, 80, 20));
612        assert!(!sb.handle_event(&Event::PressRelease { x: 10, y: 10 }));
613    }
614}