Skip to main content

elegance/
text_area.rs

1//! Styled multi-line text input — the multiline companion to
2//! [`TextInput`](crate::TextInput).
3//!
4//! Wraps [`egui::TextEdit::multiline`] with the same visual treatment as
5//! `TextInput`: the slate input background, a 1-px border that turns
6//! sky-coloured on focus, optional label, hint, dirty bar, and submit-
7//! flash feedback via [`ResponseFlashExt`](crate::ResponseFlashExt).
8
9use egui::{
10    CornerRadius, FontId, FontSelection, Response, Stroke, TextEdit, Ui, Vec2, Widget, WidgetInfo,
11    WidgetText, WidgetType,
12};
13
14use crate::{flash, theme::Theme};
15
16/// A styled multi-line text input.
17///
18/// ```no_run
19/// # use elegance::TextArea;
20/// # egui::__run_test_ui(|ui| {
21/// let mut notes = String::new();
22/// ui.add(
23///     TextArea::new(&mut notes)
24///         .label("Notes")
25///         .hint("Jot anything down…")
26///         .rows(6),
27/// );
28/// # });
29/// ```
30///
31/// # Ids, focus, and flash state
32///
33/// As with [`TextInput`](crate::TextInput), pin a stable
34/// [`id_salt`](Self::id_salt) on any TextArea you plan to flash — otherwise
35/// the id is layout-dependent and in-flight flash/focus/cursor state is
36/// lost if siblings above this widget appear or disappear between frames.
37#[must_use = "Add with `ui.add(...)`."]
38pub struct TextArea<'a> {
39    text: &'a mut String,
40    label: Option<WidgetText>,
41    hint: Option<&'a str>,
42    dirty: bool,
43    rows: usize,
44    desired_width: Option<f32>,
45    monospace: bool,
46    id_salt: Option<egui::Id>,
47}
48
49impl<'a> std::fmt::Debug for TextArea<'a> {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("TextArea")
52            .field("dirty", &self.dirty)
53            .field("rows", &self.rows)
54            .field("monospace", &self.monospace)
55            .field("desired_width", &self.desired_width)
56            .finish()
57    }
58}
59
60impl<'a> TextArea<'a> {
61    /// Create a multi-line text input bound to `text`.
62    pub fn new(text: &'a mut String) -> Self {
63        Self {
64            text,
65            label: None,
66            hint: None,
67            dirty: false,
68            rows: 5,
69            desired_width: None,
70            monospace: false,
71            id_salt: None,
72        }
73    }
74
75    /// Show a label above the text area.
76    pub fn label(mut self, text: impl Into<WidgetText>) -> Self {
77        self.label = Some(text.into());
78        self
79    }
80
81    /// Show placeholder-style hint text when the field is empty.
82    pub fn hint(mut self, text: &'a str) -> Self {
83        self.hint = Some(text);
84        self
85    }
86
87    /// Mark the input as having unsaved changes. Shows a sky-coloured
88    /// accent bar down the left side.
89    pub fn dirty(mut self, dirty: bool) -> Self {
90        self.dirty = dirty;
91        self
92    }
93
94    /// Minimum visible row count. Default: `5`.
95    pub fn rows(mut self, rows: usize) -> Self {
96        self.rows = rows.max(1);
97        self
98    }
99
100    /// Desired width (points) for the editor portion of the widget.
101    pub fn desired_width(mut self, width: f32) -> Self {
102        self.desired_width = Some(width);
103        self
104    }
105
106    /// Render the text in the theme's monospace font. Useful for code,
107    /// JSON blobs, PEM keys, and similar fixed-width content.
108    pub fn monospace(mut self, monospace: bool) -> Self {
109        self.monospace = monospace;
110        self
111    }
112
113    /// Supply a stable id salt. Required if you plan to flash this widget
114    /// via [`ResponseFlashExt`](crate::ResponseFlashExt).
115    pub fn id_salt(mut self, id: impl std::hash::Hash) -> Self {
116        self.id_salt = Some(egui::Id::new(id));
117        self
118    }
119}
120
121impl<'a> Widget for TextArea<'a> {
122    fn ui(self, ui: &mut Ui) -> Response {
123        let theme = Theme::current(ui.ctx());
124        let p = &theme.palette;
125        let t = &theme.typography;
126
127        ui.vertical(|ui| {
128            if let Some(label) = &self.label {
129                ui.add_space(2.0);
130                let rich = egui::RichText::new(label.text())
131                    .color(p.text_muted)
132                    .size(t.label);
133                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
134                ui.add_space(2.0);
135            }
136
137            let id_salt = self.id_salt.unwrap_or_else(|| ui.next_auto_id());
138            let widget_id = ui.make_persistent_id(egui::Id::new(id_salt));
139
140            let flash = flash::active_flash(ui.ctx(), widget_id);
141            let bg_fill = flash::background_fill(&theme, p.input_bg, flash);
142
143            let desired_width = self.desired_width.unwrap_or_else(|| ui.available_width());
144            let margin = Vec2::new(theme.control_padding_x * 0.5, theme.control_padding_y);
145
146            let font_id = if self.monospace {
147                FontId::monospace(t.monospace)
148            } else {
149                FontId::proportional(t.body)
150            };
151
152            let response = crate::theme::with_themed_visuals(ui, |ui| {
153                let v = ui.visuals_mut();
154                crate::theme::themed_input_visuals(v, &theme, bg_fill);
155                v.extreme_bg_color = bg_fill;
156                v.selection.bg_fill = crate::theme::with_alpha(p.sky, 90);
157                v.selection.stroke = Stroke::new(1.0, p.sky);
158
159                let mut edit = TextEdit::multiline(self.text)
160                    .id_salt(id_salt)
161                    .font(FontSelection::FontId(font_id))
162                    .text_color(p.text)
163                    .margin(margin)
164                    .desired_width(desired_width)
165                    .desired_rows(self.rows);
166                if let Some(hint) = self.hint {
167                    edit = edit.hint_text(egui::RichText::new(hint).color(p.text_faint));
168                }
169
170                ui.add(edit)
171            });
172
173            if self.dirty && ui.is_rect_visible(response.rect) {
174                // Inset by the frame stroke so the bar sits *inside* the border,
175                // and use the frame's inner radius so the bar's rounded left edge
176                // follows the same arc as the inside of the input's rounded corner.
177                let stroke_w = 1.0;
178                let bar_w = 3.0;
179                let r = ((theme.control_radius - stroke_w).max(0.0)) as u8;
180                let bar = egui::Rect::from_min_max(
181                    egui::pos2(
182                        response.rect.min.x + stroke_w,
183                        response.rect.min.y + stroke_w,
184                    ),
185                    egui::pos2(
186                        response.rect.min.x + stroke_w + bar_w,
187                        response.rect.max.y - stroke_w,
188                    ),
189                );
190                let corner = CornerRadius {
191                    nw: r,
192                    sw: r,
193                    ne: 0,
194                    se: 0,
195                };
196                ui.painter().rect_filled(bar, corner, p.sky);
197            }
198
199            if let Some(label) = &self.label {
200                let label = label.text().to_string();
201                response.widget_info(|| WidgetInfo::labeled(WidgetType::TextEdit, true, &label));
202            }
203
204            response
205        })
206        .inner
207    }
208}