elegance/input.rs
1//! Styled single-line text input.
2//!
3//! Wraps [`egui::TextEdit`] with:
4//!
5//! * The slate input background
6//! * A crisp 1-px border that turns sky-coloured on focus
7//! * An optional "dirty" indicator (a 3-px sky-coloured bar down the left
8//! edge) to signal unsaved changes
9//! * An optional label and optional hint text
10//! * Success / error flash animations triggered via
11//! [`ResponseFlashExt`](crate::ResponseFlashExt)
12
13use egui::{
14 CornerRadius, FontSelection, Response, Stroke, TextEdit, Ui, Vec2, Widget, WidgetInfo,
15 WidgetText, WidgetType,
16};
17
18use crate::{flash, theme::Theme};
19
20/// A styled single-line text input.
21///
22/// ```no_run
23/// # use elegance::TextInput;
24/// # egui::__run_test_ui(|ui| {
25/// let mut email = String::new();
26/// ui.add(TextInput::new(&mut email).label("Email").hint("you@example.com"));
27/// # });
28/// ```
29///
30/// # Ids, focus, and flash state
31///
32/// Flash animations, focus, and cursor state are keyed off the widget's
33/// egui id. Without [`id_salt`](Self::id_salt), the id is derived from
34/// egui's auto-id counter, which is layout-dependent — if a sibling
35/// appears or disappears above this input between frames, the id shifts
36/// and any in-flight flash, focus, or cursor state is lost. Any input
37/// you flash via [`ResponseFlashExt`](crate::ResponseFlashExt) should pin
38/// its id with [`id_salt`](Self::id_salt).
39#[must_use = "Add with `ui.add(...)`."]
40pub struct TextInput<'a> {
41 text: &'a mut String,
42 label: Option<WidgetText>,
43 hint: Option<&'a str>,
44 dirty: bool,
45 password: bool,
46 desired_width: Option<f32>,
47 id_salt: Option<egui::Id>,
48 compact: bool,
49}
50
51impl<'a> std::fmt::Debug for TextInput<'a> {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("TextInput")
54 .field("dirty", &self.dirty)
55 .field("password", &self.password)
56 .field("desired_width", &self.desired_width)
57 .field("compact", &self.compact)
58 .finish()
59 }
60}
61
62impl<'a> TextInput<'a> {
63 /// Create a text input bound to `text`.
64 pub fn new(text: &'a mut String) -> Self {
65 Self {
66 text,
67 label: None,
68 hint: None,
69 dirty: false,
70 password: false,
71 desired_width: None,
72 id_salt: None,
73 compact: false,
74 }
75 }
76
77 /// Show a label above the input.
78 pub fn label(mut self, text: impl Into<WidgetText>) -> Self {
79 self.label = Some(text.into());
80 self
81 }
82
83 /// Show placeholder-style hint text when the field is empty.
84 pub fn hint(mut self, text: &'a str) -> Self {
85 self.hint = Some(text);
86 self
87 }
88
89 /// Mark the input as having unsaved changes. Shows a sky-coloured
90 /// accent bar down the left side.
91 pub fn dirty(mut self, dirty: bool) -> Self {
92 self.dirty = dirty;
93 self
94 }
95
96 /// Mask the text as a password field.
97 pub fn password(mut self, password: bool) -> Self {
98 self.password = password;
99 self
100 }
101
102 /// Desired width (points) for the editor portion of the widget.
103 pub fn desired_width(mut self, width: f32) -> Self {
104 self.desired_width = Some(width);
105 self
106 }
107
108 /// Supply a stable id salt. Useful when two inputs share the same label.
109 pub fn id_salt(mut self, id: impl std::hash::Hash) -> Self {
110 self.id_salt = Some(egui::Id::new(id));
111 self
112 }
113
114 /// Render with reduced vertical padding so the input matches the
115 /// height of [`RemovableChip`](crate::RemovableChip) and small-size
116 /// controls. Useful for inline path / chip rows where a full-height
117 /// input would dominate. Default: `false` (standard control height).
118 pub fn compact(mut self, compact: bool) -> Self {
119 self.compact = compact;
120 self
121 }
122}
123
124impl<'a> Widget for TextInput<'a> {
125 fn ui(self, ui: &mut Ui) -> Response {
126 let theme = Theme::current(ui.ctx());
127 let p = &theme.palette;
128 let t = &theme.typography;
129
130 ui.vertical(|ui| {
131 if let Some(label) = &self.label {
132 ui.add_space(2.0);
133 let rich = egui::RichText::new(label.text())
134 .color(p.text_muted)
135 .size(t.label);
136 ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
137 ui.add_space(2.0);
138 }
139
140 // Pin a stable id_salt so the TextEdit id is predictable —
141 // we need it to look up flash state before painting.
142 //
143 // `TextEdit::id_salt` internally wraps its input in
144 // `Id::new(...)` before calling `make_persistent_id`, so we
145 // mirror that step here to get the *same* widget id.
146 let id_salt = self.id_salt.unwrap_or_else(|| ui.next_auto_id());
147 let widget_id = ui.make_persistent_id(egui::Id::new(id_salt));
148
149 let flash = flash::active_flash(ui.ctx(), widget_id);
150 let bg_fill = flash::background_fill(&theme, p.input_bg, flash);
151
152 let desired_width = self.desired_width.unwrap_or_else(|| ui.available_width());
153 // Compact mode shrinks the vertical padding to match
154 // RemovableChip's editor and Button::size(Small) at ~22 pt
155 // total. Horizontal padding is unchanged so the caret has
156 // room either way.
157 let margin_y = if self.compact {
158 3.0
159 } else {
160 theme.control_padding_y
161 };
162 let margin = Vec2::new(theme.control_padding_x * 0.5, margin_y);
163
164 // Swap visuals so the TextEdit picks up our look, then restore.
165 let response = crate::theme::with_themed_visuals(ui, |ui| {
166 let v = ui.visuals_mut();
167 crate::theme::themed_input_visuals(v, &theme, bg_fill);
168 v.extreme_bg_color = bg_fill;
169 v.selection.bg_fill = crate::theme::with_alpha(p.sky, 90);
170 v.selection.stroke = Stroke::new(1.0, p.sky);
171
172 let mut edit = TextEdit::singleline(self.text)
173 .id_salt(id_salt)
174 .font(FontSelection::FontId(egui::FontId::proportional(t.body)))
175 .text_color(p.text)
176 .margin(margin)
177 .desired_width(desired_width);
178 if let Some(hint) = self.hint {
179 edit = edit.hint_text(egui::RichText::new(hint).color(p.text_faint));
180 }
181 if self.password {
182 edit = edit.password(true);
183 }
184
185 ui.add(edit)
186 });
187
188 if self.dirty && ui.is_rect_visible(response.rect) {
189 // Hug the inside of the border with a fixed geometry that does
190 // *not* change across hover/focus. The bar is a status
191 // indicator, not an interactive element — jittering it with the
192 // cursor reads as a bug. All input states use a 1 pt stroke
193 // (only the colour changes on focus), so inset = 1.0 matches
194 // the inner edge of the border in every state, and the bar's
195 // 5 pt inner corner radius matches the border's inner arc.
196 let stroke_w = 1.0;
197 let bar_w = 3.0;
198 let r = (theme.control_radius - stroke_w).max(0.0) as u8;
199 let bar = egui::Rect::from_min_max(
200 egui::pos2(
201 response.rect.min.x + stroke_w,
202 response.rect.min.y + stroke_w,
203 ),
204 egui::pos2(
205 response.rect.min.x + stroke_w + bar_w,
206 response.rect.max.y - stroke_w,
207 ),
208 );
209 let corner = CornerRadius {
210 nw: r,
211 sw: r,
212 ne: 0,
213 se: 0,
214 };
215 ui.painter().rect_filled(bar, corner, p.sky);
216 }
217
218 // Expose the field label via accesskit so screen readers announce
219 // the field purpose. TextEdit sets its own widget_info (with the
220 // current text value as the label); replacing it with ours makes
221 // the field queryable by its semantic label instead.
222 if let Some(label) = &self.label {
223 let label = label.text().to_string();
224 response.widget_info(|| WidgetInfo::labeled(WidgetType::TextEdit, true, &label));
225 }
226
227 response
228 })
229 .inner
230 }
231}