Skip to main content

egui_components/
number_input.rs

1//! `NumberInput` — a numeric field flanked by `−` / `+` stepper buttons.
2//!
3//! Wraps a single-line [`egui::TextEdit`] in the same themed frame as
4//! [`Input`](crate::input::Input), keeps an internal text buffer so partial
5//! edits (e.g. `"1."`) don't fight the bound value, and clamps to
6//! `[min, max]`. Edits the bound `f64`; the returned [`Response`] reports
7//! `.changed()` whenever the value changes.
8//!
9//! ```ignore
10//! ui.add(sc::NumberInput::new(&mut qty).range(0.0..=99.0).step(1.0));
11//! ```
12
13use std::ops::RangeInclusive;
14
15use crate::common::Size;
16use egui::{pos2, vec2, FontId, Rect, Response, Sense, Stroke, Ui, Widget};
17use egui_components_theme::{mix, Theme};
18
19pub struct NumberInput<'a> {
20    value: &'a mut f64,
21    min: f64,
22    max: f64,
23    step: f64,
24    precision: usize,
25    width: Option<f32>,
26    disabled: bool,
27    size: Size,
28}
29
30impl<'a> NumberInput<'a> {
31    pub fn new(value: &'a mut f64) -> Self {
32        Self {
33            value,
34            min: f64::NEG_INFINITY,
35            max: f64::INFINITY,
36            step: 1.0,
37            precision: 0,
38            width: None,
39            disabled: false,
40            size: Size::Medium,
41        }
42    }
43
44    pub fn range(mut self, range: RangeInclusive<f64>) -> Self {
45        self.min = *range.start();
46        self.max = *range.end();
47        self
48    }
49    pub fn step(mut self, step: f64) -> Self {
50        self.step = step;
51        self
52    }
53    /// Number of decimal places used when displaying the value.
54    pub fn precision(mut self, p: usize) -> Self {
55        self.precision = p;
56        self
57    }
58    pub fn width(mut self, w: f32) -> Self {
59        self.width = Some(w);
60        self
61    }
62    pub fn disabled(mut self, d: bool) -> Self {
63        self.disabled = d;
64        self
65    }
66    pub fn size(mut self, s: Size) -> Self {
67        self.size = s;
68        self
69    }
70    pub fn small(self) -> Self {
71        self.size(Size::Small)
72    }
73    pub fn large(self) -> Self {
74        self.size(Size::Large)
75    }
76
77    fn format(&self, v: f64) -> String {
78        format!("{:.*}", self.precision, v)
79    }
80}
81
82impl<'a> Widget for NumberInput<'a> {
83    fn ui(self, ui: &mut Ui) -> Response {
84        let theme = Theme::get(ui.ctx());
85        let m = theme.metrics;
86        let c = theme.colors;
87
88        let height = self.size.input_height(&m);
89        let step_w = height; // square stepper buttons
90        let width = self
91            .width
92            .unwrap_or_else(|| ui.available_width().min(180.0))
93            .max(step_w * 3.0);
94        let radius = theme.corner();
95
96        let (rect, mut response) =
97            ui.allocate_exact_size(vec2(width, height), Sense::hover());
98        let buf_id = response.id.with("buf");
99
100        let minus_rect = Rect::from_min_size(rect.min, vec2(step_w, height));
101        let plus_rect =
102            Rect::from_min_size(pos2(rect.right() - step_w, rect.top()), vec2(step_w, height));
103        let field_rect = Rect::from_min_max(
104            pos2(minus_rect.right(), rect.top()),
105            pos2(plus_rect.left(), rect.bottom()),
106        );
107
108        let mut changed = false;
109        let mut value = *self.value;
110
111        // Stepper interaction.
112        let minus = ui.interact(minus_rect, response.id.with("minus"), step_sense(self.disabled));
113        let plus = ui.interact(plus_rect, response.id.with("plus"), step_sense(self.disabled));
114        if minus.clicked() {
115            value = (value - self.step).clamp(self.min, self.max);
116            changed = true;
117        }
118        if plus.clicked() {
119            value = (value + self.step).clamp(self.min, self.max);
120            changed = true;
121        }
122
123        // Text buffer kept in memory so partial input survives across frames.
124        let mut buf = ui
125            .data_mut(|d| d.get_temp::<String>(buf_id))
126            .unwrap_or_else(|| self.format(value));
127        if changed {
128            buf = self.format(value);
129        }
130
131        // The editable field.
132        let inner_rect = field_rect.shrink2(vec2(6.0, 4.0));
133        let field_resp = {
134            let mut child = ui.new_child(
135                egui::UiBuilder::new()
136                    .max_rect(inner_rect)
137                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
138            );
139            if self.disabled {
140                child.disable();
141            }
142            let edit = egui::TextEdit::singleline(&mut buf)
143                .frame(egui::Frame::NONE)
144                .desired_width(inner_rect.width())
145                .horizontal_align(egui::Align::Center)
146                .font(FontId::proportional(m.font_size_md))
147                .text_color(if self.disabled {
148                    mix(c.foreground, c.muted_foreground, 0.5)
149                } else {
150                    c.foreground
151                });
152            child.add(edit)
153        };
154
155        let has_focus = field_resp.has_focus();
156        if field_resp.changed() {
157            if let Ok(parsed) = buf.trim().parse::<f64>() {
158                value = parsed.clamp(self.min, self.max);
159                changed = true;
160            }
161        }
162        // Resync the buffer from the (possibly clamped) value once editing ends,
163        // so an out-of-range or malformed entry settles to a valid display.
164        if !has_focus {
165            buf = self.format(value);
166        }
167
168        ui.data_mut(|d| d.insert_temp(buf_id, buf));
169
170        if changed {
171            *self.value = value;
172            response.mark_changed();
173        }
174
175        // Painting.
176        if ui.is_rect_visible(rect) {
177            let painter = ui.painter();
178            let bg = if self.disabled {
179                mix(c.background, c.muted_background, 0.6)
180            } else {
181                c.background
182            };
183            painter.rect_filled(rect, radius, bg);
184
185            let border_color = if has_focus {
186                c.ring
187            } else if response.hovered() || field_resp.hovered() {
188                mix(c.input_border, c.foreground, 0.25)
189            } else {
190                c.input_border
191            };
192            painter.rect_stroke(
193                rect,
194                radius,
195                Stroke::new(m.border_width, border_color),
196                egui::StrokeKind::Inside,
197            );
198
199            // Divider lines between steppers and field.
200            let divider = Stroke::new(m.border_width, c.input_border);
201            painter.line_segment(
202                [minus_rect.right_top(), minus_rect.right_bottom()],
203                divider,
204            );
205            painter.line_segment([plus_rect.left_top(), plus_rect.left_bottom()], divider);
206
207            let minus_disabled = self.disabled || value <= self.min;
208            let plus_disabled = self.disabled || value >= self.max;
209            paint_stepper(ui, minus_rect, &minus, "−", minus_disabled, &theme);
210            paint_stepper(ui, plus_rect, &plus, "+", plus_disabled, &theme);
211
212            if has_focus {
213                ui.painter().rect_stroke(
214                    rect.expand(2.0),
215                    radius,
216                    theme.focus_ring(),
217                    egui::StrokeKind::Outside,
218                );
219            }
220        }
221
222        if !self.disabled && field_resp.hovered() {
223            ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
224        }
225
226        response | field_resp | minus | plus
227    }
228}
229
230fn step_sense(disabled: bool) -> Sense {
231    if disabled {
232        Sense::hover()
233    } else {
234        Sense::click()
235    }
236}
237
238fn paint_stepper(
239    ui: &Ui,
240    rect: Rect,
241    response: &Response,
242    glyph: &str,
243    disabled: bool,
244    theme: &Theme,
245) {
246    let c = &theme.colors;
247    let painter = ui.painter();
248    let bg = if disabled {
249        egui::Color32::TRANSPARENT
250    } else if response.is_pointer_button_down_on() {
251        c.secondary_active_background
252    } else if response.hovered() {
253        c.accent_background
254    } else {
255        egui::Color32::TRANSPARENT
256    };
257    if bg != egui::Color32::TRANSPARENT {
258        painter.rect_filled(rect, 0.0, bg);
259    }
260    let fg = if disabled {
261        mix(c.muted_foreground, c.background, 0.4)
262    } else {
263        c.foreground
264    };
265    painter.text(
266        rect.center(),
267        egui::Align2::CENTER_CENTER,
268        glyph,
269        FontId::proportional(theme.metrics.font_size_lg),
270        fg,
271    );
272    if !disabled && response.hovered() {
273        ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
274    }
275}