1use crate::ext::ArmasContextExt;
11use egui::{Color32, Response, Sense, Stroke, TextEdit, Ui, Vec2};
12
13const CORNER_RADIUS: f32 = 6.0; const HEIGHT: f32 = 36.0; const PADDING_X: f32 = 12.0; const PADDING_Y: f32 = 8.0; #[derive(Debug, Clone)]
22pub struct InputResponse {
23 pub response: Response,
25 pub text: String,
27 pub changed: bool,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum InputState {
34 #[default]
36 Normal,
37 Success,
39 Error,
41 Warning,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum InputVariant {
48 #[default]
50 Default,
51 Outlined,
53 Filled,
55 Inline,
57}
58
59pub struct Input {
79 id: Option<egui::Id>,
80 variant: InputVariant,
81 state: InputState,
82 label: Option<String>,
83 description: Option<String>,
84 placeholder: String,
85 left_icon: Option<String>,
86 right_icon: Option<String>,
87 width: Option<f32>,
88 custom_height: Option<f32>,
89 password: bool,
90 disabled: bool,
91}
92
93impl Input {
94 pub fn new(placeholder: impl Into<String>) -> Self {
96 Self {
97 id: None,
98 variant: InputVariant::Default,
99 state: InputState::Normal,
100 label: None,
101 description: None,
102 placeholder: placeholder.into(),
103 left_icon: None,
104 right_icon: None,
105 width: None,
106 custom_height: None,
107 password: false,
108 disabled: false,
109 }
110 }
111
112 #[must_use]
114 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
115 self.id = Some(id.into());
116 self
117 }
118
119 #[must_use]
121 pub const fn variant(mut self, variant: InputVariant) -> Self {
122 self.variant = variant;
123 self
124 }
125
126 #[must_use]
128 pub const fn state(mut self, state: InputState) -> Self {
129 self.state = state;
130 self
131 }
132
133 #[must_use]
135 pub fn label(mut self, label: impl Into<String>) -> Self {
136 self.label = Some(label.into());
137 self
138 }
139
140 #[must_use]
142 pub fn description(mut self, text: impl Into<String>) -> Self {
143 self.description = Some(text.into());
144 self
145 }
146
147 #[must_use]
149 pub fn helper_text(mut self, text: impl Into<String>) -> Self {
150 self.description = Some(text.into());
151 self
152 }
153
154 #[must_use]
156 pub fn left_icon(mut self, icon: impl Into<String>) -> Self {
157 self.left_icon = Some(icon.into());
158 self
159 }
160
161 #[must_use]
163 pub fn right_icon(mut self, icon: impl Into<String>) -> Self {
164 self.right_icon = Some(icon.into());
165 self
166 }
167
168 #[must_use]
170 pub const fn width(mut self, width: f32) -> Self {
171 self.width = Some(width);
172 self
173 }
174
175 #[must_use]
177 pub const fn height(mut self, height: f32) -> Self {
178 self.custom_height = Some(height);
179 self
180 }
181
182 #[must_use]
184 pub const fn password(mut self, enabled: bool) -> Self {
185 self.password = enabled;
186 self
187 }
188
189 #[must_use]
191 pub const fn disabled(mut self, disabled: bool) -> Self {
192 self.disabled = disabled;
193 self
194 }
195
196 #[must_use]
198 pub const fn font_size(self, _size: f32) -> Self {
199 self
201 }
202
203 #[must_use]
205 pub const fn text_color(self, _color: Color32) -> Self {
206 self
208 }
209
210 pub fn show(self, ui: &mut Ui, text: &mut String) -> InputResponse {
212 let theme = ui.ctx().armas_theme();
213 if let Some(id) = self.id {
215 let state_id = id.with("input_state");
216 let stored_text: String = ui
217 .ctx()
218 .data_mut(|d| d.get_temp(state_id).unwrap_or_else(|| text.clone()));
219 *text = stored_text;
220 }
221
222 let width = self.width.unwrap_or(200.0);
223
224 let response = ui.vertical(|ui| {
225 ui.spacing_mut().item_spacing.y = 6.0; if let Some(label) = &self.label {
229 ui.label(
230 egui::RichText::new(label)
231 .size(theme.typography.base)
232 .color(if self.disabled {
233 theme.muted_foreground()
234 } else {
235 theme.foreground()
236 }),
237 );
238 }
239
240 let input_response = self.render_input(ui, text, width, &theme);
242
243 if let Some(desc) = &self.description {
245 let desc_color = match self.state {
246 InputState::Normal => theme.muted_foreground(),
247 InputState::Success => theme.chart_2(),
248 InputState::Error => theme.destructive(),
249 InputState::Warning => theme.chart_3(),
250 };
251 ui.label(
252 egui::RichText::new(desc)
253 .size(theme.typography.sm)
254 .color(desc_color),
255 );
256 }
257
258 input_response
259 });
260
261 if let Some(id) = self.id {
263 let state_id = id.with("input_state");
264 ui.ctx().data_mut(|d| {
265 d.insert_temp(state_id, text.clone());
266 });
267 }
268
269 let inner_response = response.inner;
270 let changed = inner_response.changed();
271 let text_clone = text.clone();
272
273 InputResponse {
274 response: inner_response,
275 text: text_clone,
276 changed,
277 }
278 }
279
280 fn render_input(
281 &self,
282 ui: &mut Ui,
283 text: &mut String,
284 width: f32,
285 theme: &crate::Theme,
286 ) -> Response {
287 let height = self
288 .custom_height
289 .unwrap_or(if self.variant == InputVariant::Inline {
290 28.0
291 } else {
292 HEIGHT
293 });
294
295 let border_color = match self.state {
297 InputState::Normal => theme.input(),
298 InputState::Success => theme.chart_2(),
299 InputState::Error => theme.destructive(),
300 InputState::Warning => theme.chart_3(),
301 };
302
303 let bg_color = if self.disabled || self.variant == InputVariant::Filled {
305 theme.muted()
306 } else {
307 theme.background()
308 };
309
310 let text_color = if self.disabled {
312 theme.muted_foreground()
313 } else {
314 theme.foreground()
315 };
316
317 let placeholder_color = theme.muted_foreground();
318
319 let desired_size = Vec2::new(width, height);
321 let (rect, response) = ui.allocate_exact_size(
322 desired_size,
323 if self.disabled {
324 Sense::hover()
325 } else {
326 Sense::click_and_drag()
327 },
328 );
329
330 if ui.is_rect_visible(rect) {
331 let painter = ui.painter();
332
333 painter.rect_filled(rect, CORNER_RADIUS, bg_color);
335
336 let stroke_width = if response.has_focus() { 2.0 } else { 1.0 };
338 let stroke_color = if response.has_focus() {
339 theme.ring()
340 } else {
341 border_color
342 };
343 painter.rect_stroke(
344 rect,
345 CORNER_RADIUS,
346 Stroke::new(stroke_width, stroke_color),
347 egui::StrokeKind::Inside,
348 );
349
350 let base_font = theme.typography.base;
352 let font_size = if height < base_font + PADDING_Y * 2.0 {
353 (height * 0.6).max(8.0)
354 } else {
355 base_font
356 };
357 let content_rect = rect.shrink2(Vec2::new(PADDING_X, 0.0));
358
359 let mut x_offset = content_rect.left();
361
362 if let Some(icon) = &self.left_icon {
364 let icon_galley = painter.layout_no_wrap(
365 icon.clone(),
366 egui::FontId::proportional(16.0),
367 placeholder_color,
368 );
369 let icon_pos = egui::pos2(
370 x_offset,
371 content_rect.center().y - icon_galley.size().y / 2.0,
372 );
373 painter.galley(icon_pos, icon_galley, placeholder_color);
374 x_offset += 24.0; }
376
377 let right_icon_width = if self.right_icon.is_some() { 24.0 } else { 0.0 };
379
380 let text_rect = egui::Rect::from_min_max(
382 egui::pos2(x_offset, content_rect.top()),
383 egui::pos2(
384 content_rect.right() - right_icon_width,
385 content_rect.bottom(),
386 ),
387 );
388
389 if let Some(icon) = &self.right_icon {
391 let icon_galley = painter.layout_no_wrap(
392 icon.clone(),
393 egui::FontId::proportional(16.0),
394 placeholder_color,
395 );
396 let icon_x = content_rect.right() - icon_galley.size().x;
397 let icon_pos =
398 egui::pos2(icon_x, content_rect.center().y - icon_galley.size().y / 2.0);
399 painter.galley(icon_pos, icon_galley, placeholder_color);
400 }
401
402 let mut child_ui = ui.new_child(
404 egui::UiBuilder::new()
405 .max_rect(text_rect)
406 .layout(egui::Layout::left_to_right(egui::Align::Center)),
407 );
408
409 child_ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
411 child_ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
412 child_ui.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
413 child_ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
414 child_ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
415 child_ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
416 child_ui.style_mut().visuals.override_text_color = Some(text_color);
417 child_ui
418 .style_mut()
419 .text_styles
420 .insert(egui::TextStyle::Body, egui::FontId::proportional(font_size));
421
422 let mut text_edit = TextEdit::singleline(text)
423 .hint_text(&self.placeholder)
424 .desired_width(text_rect.width())
425 .frame(false)
426 .font(egui::TextStyle::Body)
427 .vertical_align(egui::Align::Center)
428 .interactive(!self.disabled);
429
430 if self.password {
431 text_edit = text_edit.password(true);
432 }
433
434 if let Some(id) = self.id {
436 text_edit = text_edit.id(id);
437 }
438
439 return child_ui.add(text_edit);
440 }
441
442 response
443 }
444}
445
446impl Default for Input {
447 fn default() -> Self {
448 Self::new("")
449 }
450}
451
452pub struct SearchInput {
454 id: Option<egui::Id>,
455 placeholder: String,
456 width: Option<f32>,
457}
458
459impl SearchInput {
460 #[must_use]
462 pub fn new() -> Self {
463 Self {
464 id: None,
465 placeholder: "Search...".to_string(),
466 width: None,
467 }
468 }
469
470 #[must_use]
472 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
473 self.id = Some(id.into());
474 self
475 }
476
477 #[must_use]
479 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
480 self.placeholder = placeholder.into();
481 self
482 }
483
484 #[must_use]
486 pub const fn width(mut self, width: f32) -> Self {
487 self.width = Some(width);
488 self
489 }
490
491 pub fn show(self, ui: &mut Ui, text: &mut String) -> InputResponse {
493 let mut input = Input::new(&self.placeholder)
494 .left_icon("🔍")
495 .width(self.width.unwrap_or(300.0));
496
497 if let Some(id) = self.id {
498 input = input.id(id);
499 }
500
501 input.show(ui, text)
502 }
503}
504
505impl Default for SearchInput {
506 fn default() -> Self {
507 Self::new()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_input_creation() {
517 let input = Input::new("Enter text");
518 assert_eq!(input.placeholder, "Enter text");
519 assert_eq!(input.variant, InputVariant::Default);
520 assert_eq!(input.state, InputState::Normal);
521 }
522
523 #[test]
524 fn test_input_builder() {
525 let input = Input::new("Test")
526 .label("Username")
527 .description("Required field")
528 .variant(InputVariant::Outlined)
529 .state(InputState::Error);
530
531 assert_eq!(input.label, Some("Username".to_string()));
532 assert_eq!(input.description, Some("Required field".to_string()));
533 assert_eq!(input.variant, InputVariant::Outlined);
534 assert_eq!(input.state, InputState::Error);
535 }
536
537 #[test]
538 fn test_search_input() {
539 let search = SearchInput::new().placeholder("Search files...");
540 assert_eq!(search.placeholder, "Search files...");
541 }
542}