Skip to main content

armas_basic/components/
number_field.rs

1//! Number Field Component
2//!
3//! Numeric input with increment/decrement stepper buttons, styled like shadcn/vue `NumberField`.
4//! Features:
5//! - +/- stepper buttons
6//! - Min/max value constraints
7//! - Configurable step size
8//! - Direct text editing
9//! - Labels and descriptions
10//! - Disabled state
11
12use crate::ext::ArmasContextExt;
13use egui::{pos2, vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
14
15// shadcn NumberField constants
16const HEIGHT: f32 = 36.0;
17const BUTTON_WIDTH: f32 = 36.0;
18const CORNER_RADIUS: f32 = 6.0;
19// Font size resolved from theme.typography.base at show-time
20
21/// Response from the number field
22pub struct NumberFieldResponse {
23    /// The underlying egui response
24    pub response: Response,
25    /// Whether the value changed this frame
26    pub changed: bool,
27}
28
29/// Number field with +/- stepper buttons
30///
31/// # Example
32///
33/// ```ignore
34/// let mut value = 5.0;
35/// NumberField::new()
36///     .min(0.0)
37///     .max(100.0)
38///     .step(1.0)
39///     .label("Quantity")
40///     .show(ui, &mut value);
41/// ```
42pub struct NumberField {
43    id: Option<egui::Id>,
44    min: Option<f32>,
45    max: Option<f32>,
46    step: f32,
47    height: f32,
48    label: Option<String>,
49    description: Option<String>,
50    width: Option<f32>,
51    disabled: bool,
52    bg_color: Option<Color32>,
53}
54
55impl NumberField {
56    /// Create a new number field with step of 1.0
57    #[must_use]
58    pub const fn new() -> Self {
59        Self {
60            id: None,
61            min: None,
62            max: None,
63            step: 1.0,
64            height: HEIGHT,
65            label: None,
66            description: None,
67            width: None,
68            disabled: false,
69            bg_color: None,
70        }
71    }
72
73    /// Set ID for state persistence
74    #[must_use]
75    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
76        self.id = Some(id.into());
77        self
78    }
79
80    /// Set minimum value
81    #[must_use]
82    pub const fn min(mut self, min: f32) -> Self {
83        self.min = Some(min);
84        self
85    }
86
87    /// Set maximum value
88    #[must_use]
89    pub const fn max(mut self, max: f32) -> Self {
90        self.max = Some(max);
91        self
92    }
93
94    /// Set step increment
95    #[must_use]
96    pub const fn step(mut self, step: f32) -> Self {
97        self.step = step;
98        self
99    }
100
101    /// Set the height of the field
102    #[must_use]
103    pub const fn height(mut self, height: f32) -> Self {
104        self.height = height;
105        self
106    }
107
108    /// Set a label above the field
109    #[must_use]
110    pub fn label(mut self, label: impl Into<String>) -> Self {
111        self.label = Some(label.into());
112        self
113    }
114
115    /// Set description/helper text below the field
116    #[must_use]
117    pub fn description(mut self, desc: impl Into<String>) -> Self {
118        self.description = Some(desc.into());
119        self
120    }
121
122    /// Set width of the entire component
123    #[must_use]
124    pub const fn width(mut self, width: f32) -> Self {
125        self.width = Some(width);
126        self
127    }
128
129    /// Set disabled state
130    #[must_use]
131    pub const fn disabled(mut self, disabled: bool) -> Self {
132        self.disabled = disabled;
133        self
134    }
135
136    /// Override the background color (default: `theme.background()`)
137    #[must_use]
138    pub const fn bg_color(mut self, color: Color32) -> Self {
139        self.bg_color = Some(color);
140        self
141    }
142
143    /// Clamp value to min/max bounds
144    fn clamp_value(&self, value: f32) -> f32 {
145        let min = self.min.unwrap_or(f32::NEG_INFINITY);
146        let max = self.max.unwrap_or(f32::INFINITY);
147        value.clamp(min, max)
148    }
149
150    /// Show the number field
151    pub fn show(self, ui: &mut Ui, value: &mut f32) -> NumberFieldResponse {
152        let theme = ui.ctx().armas_theme();
153        let total_width = self.width.unwrap_or(180.0);
154
155        // Load state from memory if ID is set
156        if let Some(id) = self.id {
157            let state_id = id.with("number_field_state");
158            let stored: f32 = ui
159                .ctx()
160                .data_mut(|d| d.get_temp(state_id).unwrap_or(*value));
161            *value = stored;
162        }
163
164        let old_value = *value;
165
166        let response = ui.vertical(|ui| {
167            ui.spacing_mut().item_spacing.y = 6.0;
168
169            // Label
170            if let Some(label) = &self.label {
171                ui.label(
172                    egui::RichText::new(label)
173                        .size(theme.typography.base)
174                        .color(if self.disabled {
175                            theme.muted_foreground()
176                        } else {
177                            theme.foreground()
178                        }),
179                );
180            }
181
182            // Number field container
183            let inner_response = self.render_field(ui, value, total_width, &theme);
184
185            // Description
186            if let Some(desc) = &self.description {
187                ui.label(
188                    egui::RichText::new(desc)
189                        .size(theme.typography.sm)
190                        .color(theme.muted_foreground()),
191                );
192            }
193
194            inner_response
195        });
196
197        let changed = (*value - old_value).abs() > f32::EPSILON;
198
199        // Save state to memory if ID is set
200        if let Some(id) = self.id {
201            let state_id = id.with("number_field_state");
202            ui.ctx().data_mut(|d| d.insert_temp(state_id, *value));
203        }
204
205        NumberFieldResponse {
206            response: response.inner,
207            changed,
208        }
209    }
210
211    fn render_field(
212        &self,
213        ui: &mut Ui,
214        value: &mut f32,
215        total_width: f32,
216        theme: &crate::Theme,
217    ) -> Response {
218        let input_width = total_width - BUTTON_WIDTH * 2.0;
219        let (rect, response) =
220            ui.allocate_exact_size(Vec2::new(total_width, self.height), Sense::hover());
221
222        if !ui.is_rect_visible(rect) {
223            return response;
224        }
225
226        let painter = ui.painter();
227        let disabled_alpha = if self.disabled { 0.5 } else { 1.0 };
228
229        // Outer container background and border
230        let bg_color = if self.disabled {
231            theme.muted()
232        } else {
233            self.bg_color.unwrap_or_else(|| theme.background())
234        };
235        let border_color = theme.input().linear_multiply(disabled_alpha);
236
237        painter.rect_filled(rect, CORNER_RADIUS, bg_color);
238        painter.rect_stroke(
239            rect,
240            CORNER_RADIUS,
241            Stroke::new(1.0, border_color),
242            egui::StrokeKind::Inside,
243        );
244
245        // Three sections: [-] | value | [+]
246        let decrement_rect = egui::Rect::from_min_size(rect.min, vec2(BUTTON_WIDTH, self.height));
247        let input_rect = egui::Rect::from_min_size(
248            rect.min + vec2(BUTTON_WIDTH, 0.0),
249            vec2(input_width, self.height),
250        );
251        let increment_rect = egui::Rect::from_min_size(
252            rect.min + vec2(BUTTON_WIDTH + input_width, 0.0),
253            vec2(BUTTON_WIDTH, self.height),
254        );
255
256        // Vertical dividers
257        painter.line_segment(
258            [
259                pos2(decrement_rect.right(), rect.top() + 1.0),
260                pos2(decrement_rect.right(), rect.bottom() - 1.0),
261            ],
262            Stroke::new(1.0, border_color),
263        );
264        painter.line_segment(
265            [
266                pos2(increment_rect.left(), rect.top() + 1.0),
267                pos2(increment_rect.left(), rect.bottom() - 1.0),
268            ],
269            Stroke::new(1.0, border_color),
270        );
271
272        // Decrement button
273        let can_decrement = !self.disabled && self.min.is_none_or(|min| *value > min);
274        let dec_response = ui.interact(
275            decrement_rect,
276            ui.id().with("dec"),
277            if can_decrement {
278                Sense::click()
279            } else {
280                Sense::hover()
281            },
282        );
283
284        if dec_response.hovered() && can_decrement {
285            painter.rect_filled(
286                decrement_rect.shrink(1.0),
287                CornerRadius {
288                    nw: (CORNER_RADIUS - 1.0) as u8,
289                    sw: (CORNER_RADIUS - 1.0) as u8,
290                    ne: 0,
291                    se: 0,
292                },
293                theme.muted(),
294            );
295        }
296
297        let minus_color = if can_decrement {
298            theme.foreground()
299        } else {
300            theme.muted_foreground().linear_multiply(0.5)
301        };
302        let minus_galley = painter.layout_no_wrap(
303            "\u{2212}".to_string(), // −
304            egui::FontId::proportional(16.0),
305            minus_color,
306        );
307        let minus_pos = decrement_rect.center() - minus_galley.size() / 2.0;
308        painter.galley(pos2(minus_pos.x, minus_pos.y), minus_galley, minus_color);
309
310        if dec_response.clicked() && can_decrement {
311            *value = self.clamp_value(*value - self.step);
312        }
313
314        // Increment button
315        let can_increment = !self.disabled && self.max.is_none_or(|max| *value < max);
316        let inc_response = ui.interact(
317            increment_rect,
318            ui.id().with("inc"),
319            if can_increment {
320                Sense::click()
321            } else {
322                Sense::hover()
323            },
324        );
325
326        if inc_response.hovered() && can_increment {
327            painter.rect_filled(
328                increment_rect.shrink(1.0),
329                CornerRadius {
330                    nw: 0,
331                    sw: 0,
332                    ne: (CORNER_RADIUS - 1.0) as u8,
333                    se: (CORNER_RADIUS - 1.0) as u8,
334                },
335                theme.muted(),
336            );
337        }
338
339        let plus_color = if can_increment {
340            theme.foreground()
341        } else {
342            theme.muted_foreground().linear_multiply(0.5)
343        };
344        let plus_galley = painter.layout_no_wrap(
345            "+".to_string(),
346            egui::FontId::proportional(16.0),
347            plus_color,
348        );
349        let plus_pos = increment_rect.center() - plus_galley.size() / 2.0;
350        painter.galley(pos2(plus_pos.x, plus_pos.y), plus_galley, plus_color);
351
352        if inc_response.clicked() && can_increment {
353            *value = self.clamp_value(*value + self.step);
354        }
355
356        // Center value display — editable text
357        let text_id = ui.id().with("number_text");
358        let is_editing: bool = ui.ctx().data_mut(|d| d.get_temp(text_id).unwrap_or(false));
359
360        if is_editing && !self.disabled {
361            // Text edit mode
362            let mut text_buf: String = ui.ctx().data_mut(|d| {
363                d.get_temp(text_id.with("buf"))
364                    .unwrap_or_else(|| format_value(*value))
365            });
366
367            let text_rect = input_rect.shrink2(vec2(4.0, 4.0));
368            let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect));
369
370            child_ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
371            child_ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
372            child_ui.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
373            child_ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
374            child_ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
375            child_ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
376            child_ui.style_mut().visuals.override_text_color = Some(theme.foreground());
377
378            let text_edit = egui::TextEdit::singleline(&mut text_buf)
379                .desired_width(text_rect.width())
380                .frame(false)
381                .font(egui::FontId::proportional(theme.typography.base))
382                .horizontal_align(egui::Align::Center)
383                .vertical_align(egui::Align::Center)
384                .id(text_id.with("edit"));
385
386            let edit_response = child_ui.add(text_edit);
387
388            // Request focus on first frame of editing
389            if !edit_response.has_focus() {
390                let first_frame: bool = ui.ctx().data_mut(|d| {
391                    let first = d.get_temp(text_id.with("first")).unwrap_or(true);
392                    if first {
393                        d.insert_temp(text_id.with("first"), false);
394                    }
395                    first
396                });
397                if first_frame {
398                    edit_response.request_focus();
399                }
400            }
401
402            // Save buffer
403            ui.ctx()
404                .data_mut(|d| d.insert_temp(text_id.with("buf"), text_buf.clone()));
405
406            // Commit on Enter or lost focus
407            if edit_response.lost_focus() {
408                if let Ok(parsed) = text_buf.parse::<f32>() {
409                    *value = self.clamp_value(parsed);
410                }
411                ui.ctx().data_mut(|d| {
412                    d.insert_temp(text_id, false);
413                    d.remove_by_type::<String>(); // clean up buf
414                });
415            }
416        } else {
417            // Display mode — show value, click to edit
418            let display_sense = if self.disabled {
419                Sense::hover()
420            } else {
421                Sense::click()
422            };
423            let input_response = ui.interact(input_rect, text_id.with("display"), display_sense);
424
425            let text_color = if self.disabled {
426                theme.muted_foreground()
427            } else {
428                theme.foreground()
429            };
430
431            let value_text = format_value(*value);
432            let value_galley = painter.layout_no_wrap(
433                value_text.clone(),
434                egui::FontId::proportional(theme.typography.base),
435                text_color,
436            );
437            let value_pos = input_rect.center() - value_galley.size() / 2.0;
438            painter.galley(pos2(value_pos.x, value_pos.y), value_galley, text_color);
439
440            // Click to enter edit mode
441            if input_response.clicked() && !self.disabled {
442                ui.ctx().data_mut(|d| {
443                    d.insert_temp(text_id, true);
444                    d.insert_temp(text_id.with("buf"), value_text);
445                    d.insert_temp(text_id.with("first"), true);
446                });
447            }
448        }
449
450        response
451    }
452}
453
454impl Default for NumberField {
455    fn default() -> Self {
456        Self::new()
457    }
458}
459
460/// Format a float value for display (strips trailing zeros)
461fn format_value(value: f32) -> String {
462    if value.fract().abs() < f32::EPSILON {
463        format!("{}", value as i64)
464    } else {
465        // Show up to 3 decimal places, strip trailing zeros
466        let s = format!("{value:.3}");
467        s.trim_end_matches('0').trim_end_matches('.').to_string()
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn test_number_field_creation() {
477        let field = NumberField::new();
478        assert_eq!(field.step, 1.0);
479        assert!(field.min.is_none());
480        assert!(field.max.is_none());
481        assert!(!field.disabled);
482    }
483
484    #[test]
485    fn test_number_field_builder() {
486        let field = NumberField::new()
487            .min(0.0)
488            .max(100.0)
489            .step(5.0)
490            .label("Count")
491            .description("Enter a number")
492            .width(200.0)
493            .disabled(true);
494
495        assert_eq!(field.min, Some(0.0));
496        assert_eq!(field.max, Some(100.0));
497        assert_eq!(field.step, 5.0);
498        assert_eq!(field.label, Some("Count".to_string()));
499        assert_eq!(field.description, Some("Enter a number".to_string()));
500        assert_eq!(field.width, Some(200.0));
501        assert!(field.disabled);
502    }
503
504    #[test]
505    fn test_clamp_value() {
506        let field = NumberField::new().min(0.0).max(10.0);
507        assert_eq!(field.clamp_value(-5.0), 0.0);
508        assert_eq!(field.clamp_value(5.0), 5.0);
509        assert_eq!(field.clamp_value(15.0), 10.0);
510    }
511
512    #[test]
513    fn test_clamp_no_bounds() {
514        let field = NumberField::new();
515        assert_eq!(field.clamp_value(-100.0), -100.0);
516        assert_eq!(field.clamp_value(100.0), 100.0);
517    }
518
519    #[test]
520    fn test_format_value() {
521        assert_eq!(format_value(5.0), "5");
522        assert_eq!(format_value(3.125), "3.125");
523        assert_eq!(format_value(0.0), "0");
524        assert_eq!(format_value(-1.0), "-1");
525        assert_eq!(format_value(2.5), "2.5");
526        assert_eq!(format_value(1.100), "1.1");
527    }
528}