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}
53
54impl NumberField {
55 #[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 #[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 #[must_use]
80 pub const fn min(mut self, min: f32) -> Self {
81 self.min = Some(min);
82 self
83 }
84
85 #[must_use]
87 pub const fn max(mut self, max: f32) -> Self {
88 self.max = Some(max);
89 self
90 }
91
92 #[must_use]
94 pub const fn step(mut self, step: f32) -> Self {
95 self.step = step;
96 self
97 }
98
99 #[must_use]
101 pub const fn height(mut self, height: f32) -> Self {
102 self.height = height;
103 self
104 }
105
106 #[must_use]
108 pub fn label(mut self, label: impl Into<String>) -> Self {
109 self.label = Some(label.into());
110 self
111 }
112
113 #[must_use]
115 pub fn description(mut self, desc: impl Into<String>) -> Self {
116 self.description = Some(desc.into());
117 self
118 }
119
120 #[must_use]
122 pub const fn width(mut self, width: f32) -> Self {
123 self.width = Some(width);
124 self
125 }
126
127 #[must_use]
129 pub const fn disabled(mut self, disabled: bool) -> Self {
130 self.disabled = disabled;
131 self
132 }
133
134 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 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 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 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 let inner_response = self.render_field(ui, value, total_width, &theme);
175
176 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 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 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 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 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 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(), 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 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 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 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 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 ui.ctx()
395 .data_mut(|d| d.insert_temp(text_id.with("buf"), text_buf.clone()));
396
397 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>(); });
406 }
407 } else {
408 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 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
451fn format_value(value: f32) -> String {
453 if value.fract().abs() < f32::EPSILON {
454 format!("{}", value as i64)
455 } else {
456 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}