1use 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 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; 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 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 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 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 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 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 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}