Skip to main content

embedded_gui/widgets/
spinbox.rs

1use core::fmt::Write as _;
2use embedded_graphics_core::pixelcolor::{Rgb565, WebColors};
3use heapless::String;
4
5use crate::{
6    block::Block,
7    geometry::Rect,
8    render::{CHAR_WIDTH, Compositor, RenderCtx, TextAlign, TextStyle},
9    style::{Border, VisualState, WidgetStyle},
10    widget::{PropertyError, PropertyKey, PropertyValue, Widget},
11};
12
13/// High-precision numeric spinbox widget with digit-level cursor selection.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct SpinboxWidget {
16    pub value: i32,
17    pub min: i32,
18    pub max: i32,
19    pub step: i32,
20    pub digits: u8,
21    pub decimals: u8,
22    pub focused_digit: u8,
23}
24
25impl SpinboxWidget {
26    pub const fn new(min: i32, max: i32, value: i32) -> Self {
27        Self {
28            value,
29            min,
30            max,
31            step: 1,
32            digits: 4,
33            decimals: 0,
34            focused_digit: 0,
35        }
36    }
37
38    pub const fn with_decimals(mut self, decimals: u8) -> Self {
39        self.decimals = decimals;
40        self
41    }
42
43    pub const fn with_digits(mut self, digits: u8) -> Self {
44        self.digits = if digits == 0 { 1 } else { digits };
45        self
46    }
47
48    pub const fn with_step(mut self, step: i32) -> Self {
49        self.step = step;
50        self
51    }
52
53    /// Step value based on currently active focused digit (10^focused_digit).
54    pub fn current_digit_multiplier(&self) -> i32 {
55        let mut mult: i32 = 1;
56        for _ in 0..self.focused_digit {
57            mult = mult.saturating_mul(10);
58        }
59        mult
60    }
61
62    /// Increments value at the current digit place.
63    pub fn increment(&mut self) {
64        let delta = self.current_digit_multiplier();
65        self.value = self.value.saturating_add(delta).clamp(self.min, self.max);
66    }
67
68    /// Decrements value at the current digit place.
69    pub fn decrement(&mut self) {
70        let delta = self.current_digit_multiplier();
71        self.value = self.value.saturating_sub(delta).clamp(self.min, self.max);
72    }
73
74    /// Moves cursor to previous (more significant / left) digit.
75    pub fn prev_digit(&mut self) {
76        if self.focused_digit + 1 < self.digits {
77            self.focused_digit += 1;
78        }
79    }
80
81    /// Moves cursor to next (less significant / right) digit.
82    pub fn next_digit(&mut self) {
83        if self.focused_digit > 0 {
84            self.focused_digit -= 1;
85        }
86    }
87
88    pub fn format_text(&self, out: &mut String<16>) {
89        out.clear();
90        let abs_val = self.value.abs();
91        let sign = if self.value < 0 { "-" } else { "" };
92
93        if self.decimals == 0 {
94            let _ = write!(
95                out,
96                "{}{:0width$}",
97                sign,
98                abs_val,
99                width = self.digits as usize
100            );
101        } else {
102            let mut divisor = 1;
103            for _ in 0..self.decimals {
104                divisor *= 10;
105            }
106            let int_part = abs_val / divisor;
107            let frac_part = abs_val % divisor;
108            let _ = write!(
109                out,
110                "{}{:0w$}.{:0d$}",
111                sign,
112                int_part,
113                frac_part,
114                w = (self.digits.saturating_sub(self.decimals)) as usize,
115                d = self.decimals as usize
116            );
117        }
118    }
119
120    pub fn render<D, C>(
121        &self,
122        ctx: &mut RenderCtx<'_, D, C>,
123        rect: Rect,
124        style: WidgetStyle,
125        state: VisualState,
126    ) -> Result<(), D::Error>
127    where
128        D: embedded_graphics_core::draw_target::DrawTarget<Color = Rgb565>,
129        C: Compositor<D>,
130    {
131        let resolved = style.resolve(state);
132        let block = Block::styled(resolved);
133        block.render(rect, ctx)?;
134
135        let inner = block.inner(rect);
136        let mut formatted: String<16> = String::new();
137        self.format_text(&mut formatted);
138
139        let char_w = CHAR_WIDTH;
140        let line_h = resolved.font.line_height();
141
142        let total_chars = formatted.len() as u32;
143        let text_w = total_chars * char_w;
144        let start_x = inner.x + (inner.w.saturating_sub(text_w) / 2) as i32;
145        let start_y = inner.y + (inner.h.saturating_sub(line_h) / 2) as i32;
146
147        // Draw formatted number
148        ctx.draw_text_in(
149            Rect::new(start_x, start_y, text_w, line_h),
150            formatted.as_str(),
151            TextStyle::new(resolved.text).with_font(resolved.font),
152        )?;
153
154        // Highlight focused digit box / underline
155        if self.focused_digit < self.digits {
156            let digit_from_right = self.focused_digit as u32
157                + if self.decimals > 0 && self.focused_digit >= self.decimals {
158                    1
159                } else {
160                    0
161                };
162            let char_idx = total_chars
163                .saturating_sub(1)
164                .saturating_sub(digit_from_right);
165            let digit_x = start_x + (char_idx * char_w) as i32;
166            let underline = Rect::new(digit_x, start_y + line_h as i32 + 1, char_w, 2);
167            ctx.fill_rect(underline, Rgb565::CSS_CYAN)?;
168        }
169
170        // Draw increment / decrement indicator chevrons on sides if width allows
171        if inner.w >= 80 {
172            let left_btn = Rect::new(inner.x + 2, inner.y + 2, 14, inner.h.saturating_sub(4));
173            let right_btn = Rect::new(
174                inner.right() - 16,
175                inner.y + 2,
176                14,
177                inner.h.saturating_sub(4),
178            );
179            ctx.stroke_rect(left_btn, Border::one(Rgb565::CSS_GRAY))?;
180            ctx.stroke_rect(right_btn, Border::one(Rgb565::CSS_GRAY))?;
181            ctx.draw_text_in(
182                left_btn,
183                "-",
184                TextStyle::new(resolved.text)
185                    .with_font(resolved.font)
186                    .with_align(TextAlign::Center),
187            )?;
188            ctx.draw_text_in(
189                right_btn,
190                "+",
191                TextStyle::new(resolved.text)
192                    .with_font(resolved.font)
193                    .with_align(TextAlign::Center),
194            )?;
195        }
196
197        Ok(())
198    }
199}
200
201impl Widget for SpinboxWidget {
202    fn render_widget_bounds(&self, _bounds: Rect, _style: &crate::style::Style) {}
203
204    fn get_property(&self, key: PropertyKey) -> Option<PropertyValue<'_>> {
205        match key {
206            PropertyKey::Value => Some(PropertyValue::Int(self.value)),
207            PropertyKey::Min => Some(PropertyValue::Int(self.min)),
208            PropertyKey::Max => Some(PropertyValue::Int(self.max)),
209            _ => None,
210        }
211    }
212
213    fn set_property<'a>(
214        &mut self,
215        key: PropertyKey,
216        val: PropertyValue<'a>,
217    ) -> Result<(), PropertyError> {
218        match (key, val) {
219            (PropertyKey::Value, PropertyValue::Int(v)) => {
220                self.value = v.clamp(self.min, self.max);
221                Ok(())
222            }
223            (PropertyKey::Min, PropertyValue::Int(m)) => {
224                self.min = m;
225                Ok(())
226            }
227            (PropertyKey::Max, PropertyValue::Int(m)) => {
228                self.max = m;
229                Ok(())
230            }
231            _ => Err(PropertyError::NotFound),
232        }
233    }
234}