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 padding_y = ((height - font_size) / 2.0).max(0.0);
358 let content_rect = rect.shrink2(Vec2::new(PADDING_X, padding_y));
359
360 let mut x_offset = content_rect.left();
362
363 if let Some(icon) = &self.left_icon {
365 let icon_galley = painter.layout_no_wrap(
366 icon.clone(),
367 egui::FontId::proportional(16.0),
368 placeholder_color,
369 );
370 let icon_pos = egui::pos2(
371 x_offset,
372 content_rect.center().y - icon_galley.size().y / 2.0,
373 );
374 painter.galley(icon_pos, icon_galley, placeholder_color);
375 x_offset += 24.0; }
377
378 let right_icon_width = if self.right_icon.is_some() { 24.0 } else { 0.0 };
380
381 let text_rect = egui::Rect::from_min_max(
383 egui::pos2(x_offset, content_rect.top()),
384 egui::pos2(
385 content_rect.right() - right_icon_width,
386 content_rect.bottom(),
387 ),
388 );
389
390 if let Some(icon) = &self.right_icon {
392 let icon_galley = painter.layout_no_wrap(
393 icon.clone(),
394 egui::FontId::proportional(16.0),
395 placeholder_color,
396 );
397 let icon_x = content_rect.right() - icon_galley.size().x;
398 let icon_pos =
399 egui::pos2(icon_x, content_rect.center().y - icon_galley.size().y / 2.0);
400 painter.galley(icon_pos, icon_galley, placeholder_color);
401 }
402
403 let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect));
405
406 child_ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
408 child_ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
409 child_ui.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
410 child_ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
411 child_ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
412 child_ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
413 child_ui.style_mut().visuals.override_text_color = Some(text_color);
414 child_ui
415 .style_mut()
416 .text_styles
417 .insert(egui::TextStyle::Body, egui::FontId::proportional(font_size));
418
419 let mut text_edit = TextEdit::singleline(text)
420 .hint_text(&self.placeholder)
421 .desired_width(text_rect.width())
422 .frame(false)
423 .font(egui::TextStyle::Body)
424 .vertical_align(egui::Align::Center)
425 .interactive(!self.disabled);
426
427 if self.password {
428 text_edit = text_edit.password(true);
429 }
430
431 if let Some(id) = self.id {
433 text_edit = text_edit.id(id);
434 }
435
436 return child_ui.add(text_edit);
437 }
438
439 response
440 }
441}
442
443impl Default for Input {
444 fn default() -> Self {
445 Self::new("")
446 }
447}
448
449pub struct SearchInput {
451 id: Option<egui::Id>,
452 placeholder: String,
453 width: Option<f32>,
454}
455
456impl SearchInput {
457 #[must_use]
459 pub fn new() -> Self {
460 Self {
461 id: None,
462 placeholder: "Search...".to_string(),
463 width: None,
464 }
465 }
466
467 #[must_use]
469 pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
470 self.id = Some(id.into());
471 self
472 }
473
474 #[must_use]
476 pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
477 self.placeholder = placeholder.into();
478 self
479 }
480
481 #[must_use]
483 pub const fn width(mut self, width: f32) -> Self {
484 self.width = Some(width);
485 self
486 }
487
488 pub fn show(self, ui: &mut Ui, text: &mut String) -> InputResponse {
490 let mut input = Input::new(&self.placeholder)
491 .left_icon("🔍")
492 .width(self.width.unwrap_or(300.0));
493
494 if let Some(id) = self.id {
495 input = input.id(id);
496 }
497
498 input.show(ui, text)
499 }
500}
501
502impl Default for SearchInput {
503 fn default() -> Self {
504 Self::new()
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_input_creation() {
514 let input = Input::new("Enter text");
515 assert_eq!(input.placeholder, "Enter text");
516 assert_eq!(input.variant, InputVariant::Default);
517 assert_eq!(input.state, InputState::Normal);
518 }
519
520 #[test]
521 fn test_input_builder() {
522 let input = Input::new("Test")
523 .label("Username")
524 .description("Required field")
525 .variant(InputVariant::Outlined)
526 .state(InputState::Error);
527
528 assert_eq!(input.label, Some("Username".to_string()));
529 assert_eq!(input.description, Some("Required field".to_string()));
530 assert_eq!(input.variant, InputVariant::Outlined);
531 assert_eq!(input.state, InputState::Error);
532 }
533
534 #[test]
535 fn test_search_input() {
536 let search = SearchInput::new().placeholder("Search files...");
537 assert_eq!(search.placeholder, "Search files...");
538 }
539}