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