Skip to main content

armas_basic/components/
textarea.rs

1//! Textarea Component
2//!
3//! Multi-line text input field styled like shadcn/ui Textarea.
4//! Provides a clean, accessible textarea with support for:
5//! - Labels and descriptions
6//! - Validation states (error, success, warning)
7//! - Character count limits
8//! - Resizable option
9
10use crate::ext::ArmasContextExt;
11use crate::{InputState, InputVariant};
12use egui::{Color32, Response, Stroke, TextEdit, Ui};
13
14// shadcn Textarea constants
15const CORNER_RADIUS: f32 = 6.0; // rounded-md
16const MIN_HEIGHT: f32 = 80.0; // Minimum height
17const PADDING: f32 = 12.0; // px-3 py-2
18                           // Font size resolved from theme.typography.base at show-time
19
20/// Response from the textarea
21#[derive(Debug, Clone)]
22pub struct TextareaResponse {
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/// Multi-line text input field styled like shadcn/ui
32///
33/// # Example
34///
35/// ```rust,no_run
36/// # use egui::Ui;
37/// # fn example(ui: &mut Ui) {
38/// use armas_basic::components::Textarea;
39///
40/// let mut text = String::new();
41/// Textarea::new("Enter your message...")
42///     .rows(4)
43///     .show(ui, &mut text);
44/// # }
45/// ```
46pub struct Textarea {
47    id: Option<egui::Id>,
48    variant: InputVariant,
49    state: InputState,
50    label: Option<String>,
51    description: Option<String>,
52    placeholder: String,
53    width: Option<f32>,
54    rows: usize,
55    max_chars: Option<usize>,
56    resizable: bool,
57    disabled: bool,
58}
59
60impl Textarea {
61    /// Create a new textarea
62    pub fn new(placeholder: impl Into<String>) -> Self {
63        Self {
64            id: None,
65            variant: InputVariant::Default,
66            state: InputState::Normal,
67            label: None,
68            description: None,
69            placeholder: placeholder.into(),
70            width: None,
71            rows: 4,
72            max_chars: None,
73            resizable: true,
74            disabled: false,
75        }
76    }
77
78    /// Set ID for state persistence
79    #[must_use]
80    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
81        self.id = Some(id.into());
82        self
83    }
84
85    /// Set the textarea variant (for backwards compatibility)
86    #[must_use]
87    pub const fn variant(mut self, variant: InputVariant) -> Self {
88        self.variant = variant;
89        self
90    }
91
92    /// Set the validation state
93    #[must_use]
94    pub const fn state(mut self, state: InputState) -> Self {
95        self.state = state;
96        self
97    }
98
99    /// Set a label above the textarea
100    #[must_use]
101    pub fn label(mut self, label: impl Into<String>) -> Self {
102        self.label = Some(label.into());
103        self
104    }
105
106    /// Set description/helper text below the textarea
107    #[must_use]
108    pub fn description(mut self, text: impl Into<String>) -> Self {
109        self.description = Some(text.into());
110        self
111    }
112
113    /// Alias for description (backwards compatibility)
114    #[must_use]
115    pub fn helper_text(mut self, text: impl Into<String>) -> Self {
116        self.description = Some(text.into());
117        self
118    }
119
120    /// Set fixed width
121    #[must_use]
122    pub const fn width(mut self, width: f32) -> Self {
123        self.width = Some(width);
124        self
125    }
126
127    /// Set number of visible rows
128    #[must_use]
129    pub fn rows(mut self, rows: usize) -> Self {
130        self.rows = rows.max(1);
131        self
132    }
133
134    /// Set maximum character count
135    #[must_use]
136    pub const fn max_chars(mut self, max: usize) -> Self {
137        self.max_chars = Some(max);
138        self
139    }
140
141    /// Set whether the textarea is resizable
142    #[must_use]
143    pub const fn resizable(mut self, resizable: bool) -> Self {
144        self.resizable = resizable;
145        self
146    }
147
148    /// Set disabled state
149    #[must_use]
150    pub const fn disabled(mut self, disabled: bool) -> Self {
151        self.disabled = disabled;
152        self
153    }
154
155    /// Show the textarea
156    pub fn show(self, ui: &mut Ui, text: &mut String) -> TextareaResponse {
157        let theme = ui.ctx().armas_theme();
158
159        // Load state from memory if ID is set
160        if let Some(id) = self.id {
161            let state_id = id.with("textarea_state");
162            let stored_text: String = ui
163                .ctx()
164                .data_mut(|d| d.get_temp(state_id).unwrap_or_else(|| text.clone()));
165            *text = stored_text;
166        }
167
168        let width = self.width.unwrap_or(300.0);
169
170        let response = ui
171            .vertical(|ui| {
172                ui.spacing_mut().item_spacing.y = 6.0; // gap-1.5
173
174                // Label with optional character count
175                if let Some(label) = &self.label {
176                    ui.horizontal(|ui| {
177                        ui.label(
178                            egui::RichText::new(label)
179                                .size(theme.typography.base)
180                                .color(if self.disabled {
181                                    theme.muted_foreground()
182                                } else {
183                                    theme.foreground()
184                                }),
185                        );
186
187                        // Character count on the right
188                        if let Some(max) = self.max_chars {
189                            ui.with_layout(
190                                egui::Layout::right_to_left(egui::Align::Center),
191                                |ui| {
192                                    let count_color = if text.len() > max {
193                                        theme.destructive()
194                                    } else if text.len() as f32 / max as f32 > 0.9 {
195                                        theme.chart_3()
196                                    } else {
197                                        theme.muted_foreground()
198                                    };
199                                    ui.label(
200                                        egui::RichText::new(format!("{}/{}", text.len(), max))
201                                            .size(theme.typography.sm)
202                                            .color(count_color),
203                                    );
204                                },
205                            );
206                        }
207                    });
208                }
209
210                // Calculate height based on rows
211                let line_height = ui.text_style_height(&egui::TextStyle::Body);
212                let min_height = (line_height * self.rows as f32 + PADDING * 2.0).max(MIN_HEIGHT);
213
214                // Border color based on state
215                let border_color = match self.state {
216                    InputState::Normal => theme.input(),
217                    InputState::Success => theme.chart_2(),
218                    InputState::Error => theme.destructive(),
219                    InputState::Warning => theme.chart_3(),
220                };
221
222                // Background color
223                let bg_color = if self.disabled || self.variant == InputVariant::Filled {
224                    theme.muted()
225                } else {
226                    theme.background()
227                };
228
229                // Text color
230                let text_color = if self.disabled {
231                    theme.muted_foreground()
232                } else {
233                    theme.foreground()
234                };
235
236                // Frame for the textarea
237                let frame = egui::Frame::NONE
238                    .fill(bg_color)
239                    .stroke(Stroke::new(1.0, border_color))
240                    .corner_radius(CORNER_RADIUS)
241                    .inner_margin(PADDING);
242
243                let response = frame.show(ui, |ui| {
244                    ui.set_width(width - PADDING * 2.0);
245                    ui.set_min_height(min_height - PADDING * 2.0);
246
247                    // Style the text edit
248                    ui.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT;
249                    ui.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT;
250                    ui.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT;
251                    ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
252                    ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
253                    ui.style_mut().visuals.widgets.active.bg_stroke = Stroke::NONE;
254                    ui.style_mut().visuals.override_text_color = Some(text_color);
255                    ui.style_mut().text_styles.insert(
256                        egui::TextStyle::Body,
257                        egui::FontId::proportional(theme.typography.base),
258                    );
259
260                    let mut text_edit = TextEdit::multiline(text)
261                        .hint_text(&self.placeholder)
262                        .desired_width(width - PADDING * 4.0)
263                        .desired_rows(self.rows)
264                        .frame(false)
265                        .interactive(!self.disabled);
266
267                    if !self.resizable {
268                        text_edit = text_edit.desired_rows(self.rows);
269                    }
270
271                    let response = ui.add(text_edit);
272
273                    // Enforce max characters
274                    if let Some(max) = self.max_chars {
275                        if text.len() > max {
276                            text.truncate(max);
277                        }
278                    }
279
280                    response
281                });
282
283                // Description/helper text
284                if let Some(desc) = &self.description {
285                    let desc_color = match self.state {
286                        InputState::Normal => theme.muted_foreground(),
287                        InputState::Success => theme.chart_2(),
288                        InputState::Error => theme.destructive(),
289                        InputState::Warning => theme.chart_3(),
290                    };
291                    ui.label(
292                        egui::RichText::new(desc)
293                            .size(theme.typography.sm)
294                            .color(desc_color),
295                    );
296                }
297
298                response.inner
299            })
300            .inner;
301
302        // Save state to memory if ID is set
303        if let Some(id) = self.id {
304            let state_id = id.with("textarea_state");
305            ui.ctx().data_mut(|d| {
306                d.insert_temp(state_id, text.clone());
307            });
308        }
309
310        let changed = response.changed();
311        let text_clone = text.clone();
312
313        TextareaResponse {
314            response,
315            text: text_clone,
316            changed,
317        }
318    }
319}
320
321impl Default for Textarea {
322    fn default() -> Self {
323        Self::new("")
324    }
325}