Skip to main content

armas_basic/components/
input.rs

1//! Input Component
2//!
3//! Text input field styled like shadcn/ui Input.
4//! Provides a clean, accessible text input with support for:
5//! - Labels and descriptions
6//! - Validation states (error, success, warning)
7//! - Icons (left and right)
8//! - Password masking
9
10use crate::ext::ArmasContextExt;
11use egui::{Color32, Response, Sense, Stroke, TextEdit, Ui, Vec2};
12
13// shadcn Input constants
14const CORNER_RADIUS: f32 = 6.0; // rounded-md
15const HEIGHT: f32 = 36.0; // h-9
16const PADDING_X: f32 = 12.0; // px-3
17const PADDING_Y: f32 = 8.0; // py-2
18                            // Font size resolved from theme.typography.base at show-time
19
20/// Response from the input field
21#[derive(Debug, Clone)]
22pub struct InputResponse {
23    /// The UI response
24    pub response: Response,
25    /// Current text value
26    pub text: String,
27    /// Whether text changed this frame
28    pub changed: bool,
29}
30
31/// Input validation state
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum InputState {
34    /// Normal state
35    #[default]
36    Normal,
37    /// Success/valid state
38    Success,
39    /// Error/invalid state
40    Error,
41    /// Warning state
42    Warning,
43}
44
45/// Input field variant (for backwards compatibility)
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
47pub enum InputVariant {
48    /// Default input style (standard shadcn input)
49    #[default]
50    Default,
51    /// Outlined input (same as default in shadcn)
52    Outlined,
53    /// Filled background style
54    Filled,
55    /// Inline edit style - minimal chrome
56    Inline,
57}
58
59/// Text input field styled like shadcn/ui
60///
61/// # Example
62///
63/// ```rust,no_run
64/// # use egui::Ui;
65/// # fn example(ui: &mut Ui) {
66/// use armas_basic::components::Input;
67///
68/// let mut text = String::new();
69/// let response = Input::new("Email")
70///     .label("Email address")
71///     .show(ui, &mut text);
72///
73/// if response.changed {
74///     // text was modified
75/// }
76/// # }
77/// ```
78pub 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    /// Create a new input field
95    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    /// Set ID for state persistence
113    #[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    /// Set the variant (for backwards compatibility)
120    #[must_use]
121    pub const fn variant(mut self, variant: InputVariant) -> Self {
122        self.variant = variant;
123        self
124    }
125
126    /// Set the validation state
127    #[must_use]
128    pub const fn state(mut self, state: InputState) -> Self {
129        self.state = state;
130        self
131    }
132
133    /// Set a label above the input
134    #[must_use]
135    pub fn label(mut self, label: impl Into<String>) -> Self {
136        self.label = Some(label.into());
137        self
138    }
139
140    /// Set description/helper text below the input
141    #[must_use]
142    pub fn description(mut self, text: impl Into<String>) -> Self {
143        self.description = Some(text.into());
144        self
145    }
146
147    /// Alias for description (backwards compatibility)
148    #[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    /// Set left icon (emoji or character)
155    #[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    /// Set right icon (emoji or character)
162    #[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    /// Set width
169    #[must_use]
170    pub const fn width(mut self, width: f32) -> Self {
171        self.width = Some(width);
172        self
173    }
174
175    /// Set explicit height (overrides variant-based height)
176    #[must_use]
177    pub const fn height(mut self, height: f32) -> Self {
178        self.custom_height = Some(height);
179        self
180    }
181
182    /// Set as password field
183    #[must_use]
184    pub const fn password(mut self, enabled: bool) -> Self {
185        self.password = enabled;
186        self
187    }
188
189    /// Set disabled state
190    #[must_use]
191    pub const fn disabled(mut self, disabled: bool) -> Self {
192        self.disabled = disabled;
193        self
194    }
195
196    /// Backwards compatibility aliases
197    #[must_use]
198    pub const fn font_size(self, _size: f32) -> Self {
199        // Ignored - use consistent sizing
200        self
201    }
202
203    /// Set text color (currently ignored - uses theme colors)
204    #[must_use]
205    pub const fn text_color(self, _color: Color32) -> Self {
206        // Ignored - use theme colors
207        self
208    }
209
210    /// Show the input field
211    pub fn show(self, ui: &mut Ui, text: &mut String) -> InputResponse {
212        let theme = ui.ctx().armas_theme();
213        // Load state from memory if ID is set
214        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; // gap-1.5
226
227            // Label
228            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            // Input field
241            let input_response = self.render_input(ui, text, width, &theme);
242
243            // Description/helper text
244            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        // Save state to memory if ID is set
262        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        // Calculate border color based on state
296        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        // Background color
304        let bg_color = if self.disabled || self.variant == InputVariant::Filled {
305            theme.muted()
306        } else {
307            theme.background()
308        };
309
310        // Text color
311        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        // Allocate space for the input
320        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            // Draw background
334            painter.rect_filled(rect, CORNER_RADIUS, bg_color);
335
336            // Draw border
337            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            // Scale font and padding to fit custom height
351            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            // Layout: [left_icon] [text_input] [right_icon]
361            let mut x_offset = content_rect.left();
362
363            // Left icon
364            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; // icon width + spacing
376            }
377
378            // Right icon offset calculation
379            let right_icon_width = if self.right_icon.is_some() { 24.0 } else { 0.0 };
380
381            // Text input area
382            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            // Right icon
391            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            // Render text edit in the allocated space
404            let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect));
405
406            // Style the text edit
407            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            // Apply ID to TextEdit if provided
432            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
449/// Search input with built-in search icon
450pub struct SearchInput {
451    id: Option<egui::Id>,
452    placeholder: String,
453    width: Option<f32>,
454}
455
456impl SearchInput {
457    /// Create a new search input
458    #[must_use]
459    pub fn new() -> Self {
460        Self {
461            id: None,
462            placeholder: "Search...".to_string(),
463            width: None,
464        }
465    }
466
467    /// Set ID for state persistence
468    #[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    /// Set placeholder text
475    #[must_use]
476    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
477        self.placeholder = placeholder.into();
478        self
479    }
480
481    /// Set width
482    #[must_use]
483    pub const fn width(mut self, width: f32) -> Self {
484        self.width = Some(width);
485        self
486    }
487
488    /// Show the search input
489    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}