armas_basic/components/
textarea.rs1use crate::ext::ArmasContextExt;
11use crate::{InputState, InputVariant};
12use egui::{Color32, Response, Stroke, TextEdit, Ui};
13
14const CORNER_RADIUS: f32 = 6.0; const MIN_HEIGHT: f32 = 80.0; const PADDING: f32 = 12.0; #[derive(Debug, Clone)]
22pub struct TextareaResponse {
23 pub response: Response,
25 pub text: String,
27 pub changed: bool,
29}
30
31pub 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 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 #[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 #[must_use]
87 pub const fn variant(mut self, variant: InputVariant) -> Self {
88 self.variant = variant;
89 self
90 }
91
92 #[must_use]
94 pub const fn state(mut self, state: InputState) -> Self {
95 self.state = state;
96 self
97 }
98
99 #[must_use]
101 pub fn label(mut self, label: impl Into<String>) -> Self {
102 self.label = Some(label.into());
103 self
104 }
105
106 #[must_use]
108 pub fn description(mut self, text: impl Into<String>) -> Self {
109 self.description = Some(text.into());
110 self
111 }
112
113 #[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 #[must_use]
122 pub const fn width(mut self, width: f32) -> Self {
123 self.width = Some(width);
124 self
125 }
126
127 #[must_use]
129 pub fn rows(mut self, rows: usize) -> Self {
130 self.rows = rows.max(1);
131 self
132 }
133
134 #[must_use]
136 pub const fn max_chars(mut self, max: usize) -> Self {
137 self.max_chars = Some(max);
138 self
139 }
140
141 #[must_use]
143 pub const fn resizable(mut self, resizable: bool) -> Self {
144 self.resizable = resizable;
145 self
146 }
147
148 #[must_use]
150 pub const fn disabled(mut self, disabled: bool) -> Self {
151 self.disabled = disabled;
152 self
153 }
154
155 pub fn show(self, ui: &mut Ui, text: &mut String) -> TextareaResponse {
157 let theme = ui.ctx().armas_theme();
158
159 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; 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 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 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 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 let bg_color = if self.disabled || self.variant == InputVariant::Filled {
224 theme.muted()
225 } else {
226 theme.background()
227 };
228
229 let text_color = if self.disabled {
231 theme.muted_foreground()
232 } else {
233 theme.foreground()
234 };
235
236 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 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 if let Some(max) = self.max_chars {
275 if text.len() > max {
276 text.truncate(max);
277 }
278 }
279
280 response
281 });
282
283 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 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}