Skip to main content

armas_basic/components/
input_group.rs

1//! Input Group Component (shadcn/ui style)
2//!
3//! A container that wraps a text input with leading/trailing addon slots
4//! for icons, text labels, or buttons.
5//!
6//! ```rust,no_run
7//! # use egui::Ui;
8//! # fn example(ui: &mut Ui) {
9//! use armas_basic::prelude::*;
10//!
11//! let mut text = String::new();
12//! InputGroup::new("search_input")
13//!     .leading(|ui| { ui.label("🔍"); })
14//!     .show(ui, &mut text);
15//! # }
16//! ```
17
18use crate::ext::ArmasContextExt;
19use egui::{vec2, Id, Sense, Stroke, Ui};
20
21// Constants
22const HEIGHT: f32 = 40.0;
23const CORNER_RADIUS: f32 = 6.0;
24const ADDON_PADDING: f32 = 10.0;
25const INPUT_PADDING: f32 = 12.0;
26// Font size resolved from theme.typography.base at show-time
27
28/// Boxed closure for addon content.
29type AddonFn = Box<dyn FnOnce(&mut Ui)>;
30
31/// Input group — a text input with leading/trailing addons.
32pub struct InputGroup {
33    id: Id,
34    width: Option<f32>,
35    placeholder: String,
36    leading: Option<AddonFn>,
37    trailing: Option<AddonFn>,
38}
39
40/// Response from an input group.
41pub struct InputGroupResponse {
42    /// The UI response.
43    pub response: egui::Response,
44    /// Whether the text changed this frame.
45    pub changed: bool,
46}
47
48impl InputGroup {
49    /// Create a new input group with a unique ID.
50    pub fn new(id: impl Into<Id>) -> Self {
51        Self {
52            id: id.into(),
53            width: None,
54            placeholder: String::new(),
55            leading: None,
56            trailing: None,
57        }
58    }
59
60    /// Set the input group width.
61    #[must_use]
62    pub const fn width(mut self, w: f32) -> Self {
63        self.width = Some(w);
64        self
65    }
66
67    /// Set placeholder text.
68    #[must_use]
69    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
70        self.placeholder = text.into();
71        self
72    }
73
74    /// Set the leading (left) addon content.
75    #[must_use]
76    pub fn leading(mut self, content: impl FnOnce(&mut Ui) + 'static) -> Self {
77        self.leading = Some(Box::new(content));
78        self
79    }
80
81    /// Set the trailing (right) addon content.
82    #[must_use]
83    pub fn trailing(mut self, content: impl FnOnce(&mut Ui) + 'static) -> Self {
84        self.trailing = Some(Box::new(content));
85        self
86    }
87
88    /// Show the input group.
89    pub fn show(self, ui: &mut Ui, text: &mut String) -> InputGroupResponse {
90        let theme = ui.ctx().armas_theme();
91        let width = self
92            .width
93            .unwrap_or_else(|| ui.available_width().min(300.0));
94
95        // Load persisted text state
96        let state_id = self.id.with("input_state");
97        let stored: Option<String> = ui.ctx().data_mut(|d| d.get_temp(state_id));
98        if let Some(stored) = stored {
99            *text = stored;
100        }
101
102        // Allocate outer rect
103        let (outer_rect, _) = ui.allocate_exact_size(vec2(width, HEIGHT), Sense::hover());
104
105        // Draw outer border
106        ui.painter()
107            .rect_filled(outer_rect, CORNER_RADIUS, theme.background());
108        ui.painter().rect_stroke(
109            outer_rect,
110            CORNER_RADIUS,
111            Stroke::new(1.0, theme.input()),
112            egui::epaint::StrokeKind::Inside,
113        );
114
115        // Track widths for layout
116        let mut leading_width = 0.0;
117        let mut trailing_width = 0.0;
118
119        // Render leading addon
120        if let Some(leading) = self.leading {
121            let addon_rect = egui::Rect::from_min_size(
122                outer_rect.left_top(),
123                vec2(outer_rect.width() * 0.3, HEIGHT), // max 30% for measurement
124            );
125
126            let mut child_ui = ui.new_child(
127                egui::UiBuilder::new()
128                    .max_rect(addon_rect)
129                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
130            );
131            child_ui.set_clip_rect(outer_rect);
132            child_ui.add_space(ADDON_PADDING);
133            child_ui.style_mut().visuals.override_text_color = Some(theme.muted_foreground());
134            leading(&mut child_ui);
135            child_ui.add_space(ADDON_PADDING);
136            leading_width = child_ui.min_rect().width();
137
138            // Draw separator between leading and input
139            let sep_x = outer_rect.left() + leading_width;
140            ui.painter().line_segment(
141                [
142                    egui::Pos2::new(sep_x, outer_rect.top() + 1.0),
143                    egui::Pos2::new(sep_x, outer_rect.bottom() - 1.0),
144                ],
145                Stroke::new(1.0, theme.border()),
146            );
147        }
148
149        // Render trailing addon (measure first)
150        if let Some(trailing) = self.trailing {
151            // Render from the right side
152            let addon_rect = egui::Rect::from_min_size(
153                egui::Pos2::new(
154                    outer_rect.right() - outer_rect.width() * 0.3,
155                    outer_rect.top(),
156                ),
157                vec2(outer_rect.width() * 0.3, HEIGHT),
158            );
159
160            let mut child_ui = ui.new_child(
161                egui::UiBuilder::new()
162                    .max_rect(addon_rect)
163                    .layout(egui::Layout::right_to_left(egui::Align::Center)),
164            );
165            child_ui.set_clip_rect(outer_rect);
166            child_ui.add_space(ADDON_PADDING);
167            child_ui.style_mut().visuals.override_text_color = Some(theme.muted_foreground());
168            trailing(&mut child_ui);
169            child_ui.add_space(ADDON_PADDING);
170            trailing_width = child_ui.min_rect().width();
171
172            // Draw separator between input and trailing
173            let sep_x = outer_rect.right() - trailing_width;
174            ui.painter().line_segment(
175                [
176                    egui::Pos2::new(sep_x, outer_rect.top() + 1.0),
177                    egui::Pos2::new(sep_x, outer_rect.bottom() - 1.0),
178                ],
179                Stroke::new(1.0, theme.border()),
180            );
181        }
182
183        // Render input in the remaining space
184        let input_left = outer_rect.left() + leading_width;
185        let input_right = outer_rect.right() - trailing_width;
186        let input_rect = egui::Rect::from_min_max(
187            egui::Pos2::new(input_left + INPUT_PADDING, outer_rect.top()),
188            egui::Pos2::new(input_right - INPUT_PADDING, outer_rect.bottom()),
189        );
190
191        let prev_text = text.clone();
192
193        let mut child_ui = ui.new_child(
194            egui::UiBuilder::new()
195                .id_salt(self.id.with("input_area"))
196                .max_rect(input_rect)
197                .layout(egui::Layout::left_to_right(egui::Align::Center)),
198        );
199        child_ui.set_clip_rect(egui::Rect::from_min_max(
200            egui::Pos2::new(input_left, outer_rect.top()),
201            egui::Pos2::new(input_right, outer_rect.bottom()),
202        ));
203
204        // Style the text edit to be borderless
205        child_ui.style_mut().visuals.extreme_bg_color = theme.background();
206        child_ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
207        child_ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
208        child_ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
209        child_ui.style_mut().visuals.widgets.inactive.bg_fill = theme.background();
210        child_ui.style_mut().visuals.widgets.hovered.bg_fill = theme.background();
211        child_ui.style_mut().visuals.widgets.active.bg_fill = theme.background();
212        child_ui.style_mut().visuals.selection.bg_fill = theme.primary();
213
214        let te = egui::TextEdit::singleline(text)
215            .id(self.id.with("input"))
216            .font(egui::TextStyle::Body)
217            .text_color(theme.foreground())
218            .desired_width(input_rect.width())
219            .hint_text(
220                egui::RichText::new(&self.placeholder)
221                    .size(theme.typography.base)
222                    .color(theme.muted_foreground()),
223            )
224            .frame(false);
225
226        let te_response = child_ui.add(te);
227        let changed = *text != prev_text;
228
229        // Focus ring on the outer border when input is focused
230        if te_response.has_focus() {
231            ui.painter().rect_stroke(
232                outer_rect,
233                CORNER_RADIUS,
234                Stroke::new(2.0, theme.ring()),
235                egui::epaint::StrokeKind::Inside,
236            );
237        }
238
239        // Persist text state
240        ui.ctx().data_mut(|d| d.insert_temp(state_id, text.clone()));
241
242        InputGroupResponse {
243            response: te_response,
244            changed,
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_input_group_builder() {
255        let ig = InputGroup::new("test")
256            .width(400.0)
257            .placeholder("Search...");
258        assert_eq!(ig.width, Some(400.0));
259        assert_eq!(ig.placeholder, "Search...");
260    }
261}