1use crate::ext::ArmasContextExt;
13use egui::{pos2, vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
14
15const HEIGHT: f32 = 36.0;
17const BUTTON_WIDTH: f32 = 36.0;
18const CORNER_RADIUS: f32 = 6.0;
19pub struct NumberFieldResponse {
23 pub response: Response,
25 pub changed: bool,
27}
28
29pub 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 #[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 #[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 #[must_use]
82 pub const fn min(mut self, min: f32) -> Self {
83 self.min = Some(min);
84 self
85 }
86
87 #[must_use]
89 pub const fn max(mut self, max: f32) -> Self {
90 self.max = Some(max);
91 self
92 }
93
94 #[must_use]
96 pub const fn step(mut self, step: f32) -> Self {
97 self.step = step;
98 self
99 }
100
101 #[must_use]
103 pub const fn height(mut self, height: f32) -> Self {
104 self.height = height;
105 self
106 }
107
108 #[must_use]
110 pub fn label(mut self, label: impl Into<String>) -> Self {
111 self.label = Some(label.into());
112 self
113 }
114
115 #[must_use]
117 pub fn description(mut self, desc: impl Into<String>) -> Self {
118 self.description = Some(desc.into());
119 self
120 }
121
122 #[must_use]
124 pub const fn width(mut self, width: f32) -> Self {
125 self.width = Some(width);
126 self
127 }
128
129 #[must_use]
131 pub const fn disabled(mut self, disabled: bool) -> Self {
132 self.disabled = disabled;
133 self
134 }
135
136 #[must_use]
138 pub const fn bg_color(mut self, color: Color32) -> Self {
139 self.bg_color = Some(color);
140 self
141 }
142
143 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 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 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 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 let inner_response = self.render_field(ui, value, total_width, &theme);
184
185 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 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 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 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 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 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(), 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 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 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 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 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 ui.ctx()
404 .data_mut(|d| d.insert_temp(text_id.with("buf"), text_buf.clone()));
405
406 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>(); });
415 }
416 } else {
417 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 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
460fn format_value(value: f32) -> String {
462 if value.fract().abs() < f32::EPSILON {
463 format!("{}", value as i64)
464 } else {
465 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}