Skip to main content

elegance/
tag_input.rs

1//! A token / chip / pill input bound to a `Vec<String>`.
2//!
3//! Renders existing tags as small accent-tinted pills and a free text
4//! field for adding more. Enter and comma commit the buffer as a new
5//! tag; with [`commit_on_space`](TagInput::commit_on_space) enabled,
6//! whitespace commits too. Backspace on an empty buffer arms the last
7//! pill (red highlight) and a second Backspace removes it; clicking a
8//! pill's `×` removes that pill. Pasted text containing commas or
9//! whitespace splits into multiple tags. Duplicates are folded
10//! case-insensitively.
11//!
12//! The caller owns the `Vec<String>`; transient typing state (current
13//! buffer, armed flag, last validation error) lives in egui memory keyed
14//! by the supplied `id_salt`.
15
16use std::hash::Hash;
17
18use egui::{
19    pos2, vec2, Color32, CornerRadius, Event, FontId, FontSelection, Id, Key, Rect, Response,
20    Sense, Stroke, StrokeKind, TextEdit, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
21};
22
23use crate::theme::{themed_input_visuals, with_alpha, with_themed_visuals, Theme};
24use crate::Accent;
25
26/// Boxed validator closure: `Ok(())` accepts the value, `Err(msg)` rejects
27/// it and surfaces `msg` as an inline error.
28type Validator<'a> = Box<dyn Fn(&str) -> Result<(), String> + 'a>;
29
30/// A pill-list text input bound to a `Vec<String>`.
31///
32/// ```no_run
33/// # use elegance::TagInput;
34/// # egui::__run_test_ui(|ui| {
35/// let mut tags: Vec<String> = vec!["rust".into(), "egui".into()];
36/// TagInput::new("tags", &mut tags)
37///     .label("Tags")
38///     .placeholder("Add a tag…")
39///     .show(ui);
40/// # });
41/// ```
42///
43/// # Email-style validator
44///
45/// Pass a [`validator`](Self::validator) closure to reject malformed
46/// commits. The widget keeps the offending text in the buffer, switches
47/// the border to the danger colour, and renders an inline error line
48/// below.
49///
50/// ```no_run
51/// # use elegance::TagInput;
52/// # egui::__run_test_ui(|ui| {
53/// let mut to: Vec<String> = Vec::new();
54/// TagInput::new("recipients", &mut to)
55///     .label("Recipients")
56///     .placeholder("Add an email…")
57///     .commit_on_space(true)
58///     .validator(|v| {
59///         if v.contains('@') && v.contains('.') {
60///             Ok(())
61///         } else {
62///             Err(format!("\"{v}\" isn't a valid email."))
63///         }
64///     })
65///     .show(ui);
66/// # });
67/// ```
68#[must_use = "Call `.show(ui)` to render the input."]
69pub struct TagInput<'a> {
70    id_salt: Id,
71    tags: &'a mut Vec<String>,
72    label: Option<WidgetText>,
73    placeholder: Option<String>,
74    accent: Accent,
75    enabled: bool,
76    commit_on_space: bool,
77    desired_width: Option<f32>,
78    validator: Option<Validator<'a>>,
79}
80
81impl<'a> std::fmt::Debug for TagInput<'a> {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("TagInput")
84            .field("tags", &self.tags)
85            .field("label", &self.label.as_ref().map(|w| w.text()))
86            .field("placeholder", &self.placeholder)
87            .field("accent", &self.accent)
88            .field("enabled", &self.enabled)
89            .field("commit_on_space", &self.commit_on_space)
90            .field("desired_width", &self.desired_width)
91            .finish()
92    }
93}
94
95impl<'a> TagInput<'a> {
96    /// Create a tag input bound to `tags`. The `id_salt` keys the buffer,
97    /// armed flag, and last-error in egui memory. Use a unique salt per
98    /// instance.
99    pub fn new(id_salt: impl Hash, tags: &'a mut Vec<String>) -> Self {
100        Self {
101            id_salt: Id::new(id_salt),
102            tags,
103            label: None,
104            placeholder: None,
105            accent: Accent::Sky,
106            enabled: true,
107            commit_on_space: false,
108            desired_width: None,
109            validator: None,
110        }
111    }
112
113    /// Show a label above the input.
114    pub fn label(mut self, text: impl Into<WidgetText>) -> Self {
115        self.label = Some(text.into());
116        self
117    }
118
119    /// Placeholder shown inside the field when empty.
120    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
121        self.placeholder = Some(text.into());
122        self
123    }
124
125    /// Pill accent colour. Default: [`Accent::Sky`].
126    pub fn accent(mut self, accent: Accent) -> Self {
127        self.accent = accent;
128        self
129    }
130
131    /// Disable the input. Disabled inputs ignore clicks, keystrokes, and
132    /// the `×` buttons. Default: enabled.
133    pub fn enabled(mut self, enabled: bool) -> Self {
134        self.enabled = enabled;
135        self
136    }
137
138    /// Treat whitespace as a commit key, in addition to Enter and comma.
139    /// Useful for email recipient fields where users frequently type
140    /// addresses separated by spaces. Default: `false`.
141    pub fn commit_on_space(mut self, on: bool) -> Self {
142        self.commit_on_space = on;
143        self
144    }
145
146    /// Desired width (points) of the framed input. Default: full
147    /// available width.
148    pub fn desired_width(mut self, width: f32) -> Self {
149        self.desired_width = Some(width);
150        self
151    }
152
153    /// Reject a candidate tag with an inline error. The closure returns
154    /// `Ok(())` for accepted values or `Err(msg)` to display `msg`
155    /// underneath the input. Rejected text stays in the buffer so the
156    /// user can fix and retry.
157    pub fn validator(mut self, f: impl Fn(&str) -> Result<(), String> + 'a) -> Self {
158        self.validator = Some(Box::new(f));
159        self
160    }
161
162    /// Render the input.
163    pub fn show(self, ui: &mut Ui) -> TagInputResponse {
164        let theme = Theme::current(ui.ctx());
165        let p = &theme.palette;
166        let t = &theme.typography;
167
168        let widget_id = ui.make_persistent_id(self.id_salt);
169        let edit_id = widget_id.with("edit");
170
171        let mut state: State = ui
172            .ctx()
173            .data(|d| d.get_temp::<State>(widget_id))
174            .unwrap_or_default();
175
176        let label_text = self.label.as_ref().map(|w| w.text().to_string());
177
178        let outer = ui
179            .vertical(|ui| {
180                if let Some(label) = self.label.as_ref() {
181                    ui.add_space(2.0);
182                    let rich = egui::RichText::new(label.text())
183                        .color(p.text_muted)
184                        .size(t.label);
185                    ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
186                    ui.add_space(2.0);
187                }
188
189                let pill_fill = with_alpha(p.accent_fill(self.accent), 41);
190                let armed_fill = with_alpha(p.danger, 56);
191                let armed_stroke = Stroke::new(1.0, with_alpha(p.danger, 153));
192                let pad_x = 6.0;
193                let pad_y = 4.0;
194                let row_gap_x = 6.0;
195                let row_gap_y = 4.0;
196
197                let total_width = self
198                    .desired_width
199                    .unwrap_or_else(|| ui.available_width())
200                    .max(120.0);
201
202                // Reserve a slot for the frame fill so we can paint it
203                // *under* the inner content once we know the focus state.
204                let bg_idx = ui.painter().add(egui::Shape::Noop);
205
206                let mut to_remove: Option<usize> = None;
207                let inner = ui.allocate_ui_with_layout(
208                    vec2(total_width, 0.0),
209                    egui::Layout::top_down(egui::Align::LEFT),
210                    |ui| {
211                        // `Frame::inner_margin` indents every wrapped row
212                        // by `pad_x` on each side. `add_space` only
213                        // affects the first row, so the TextEdit hugged
214                        // the left edge after wrapping.
215                        egui::Frame::new()
216                            .inner_margin(egui::Margin::symmetric(pad_x as i8, pad_y as i8))
217                            .show(ui, |ui| {
218                                ui.horizontal_wrapped(|ui| {
219                                    ui.spacing_mut().item_spacing = vec2(row_gap_x, row_gap_y);
220
221                                    for (i, tag) in self.tags.iter().enumerate() {
222                                        let armed = state.armed && i + 1 == self.tags.len();
223                                        let close_clicked = paint_pill(
224                                            ui,
225                                            tag,
226                                            &theme,
227                                            if armed { armed_fill } else { pill_fill },
228                                            if armed { armed_stroke } else { Stroke::NONE },
229                                            self.enabled,
230                                        );
231                                        if close_clicked {
232                                            to_remove = Some(i);
233                                        }
234                                    }
235
236                                    // The editor borrows the outer frame's
237                                    // chrome, so strip its per-state strokes.
238                                    let avail = ui.available_width().max(80.0);
239                                    with_themed_visuals(ui, |ui| {
240                                        let v = ui.visuals_mut();
241                                        themed_input_visuals(v, &theme, Color32::TRANSPARENT);
242                                        v.extreme_bg_color = Color32::TRANSPARENT;
243                                        for w in [
244                                            &mut v.widgets.inactive,
245                                            &mut v.widgets.hovered,
246                                            &mut v.widgets.active,
247                                            &mut v.widgets.open,
248                                        ] {
249                                            w.bg_stroke = Stroke::NONE;
250                                        }
251                                        v.selection.bg_fill = with_alpha(p.sky, 90);
252                                        v.selection.stroke = Stroke::new(1.0, p.sky);
253
254                                        let mut te = TextEdit::singleline(&mut state.buffer)
255                                            .id(edit_id)
256                                            .font(FontSelection::FontId(FontId::proportional(
257                                                t.body,
258                                            )))
259                                            .text_color(p.text)
260                                            .desired_width(avail)
261                                            // `.margin()` is ignored when
262                                            // a custom frame is set, so
263                                            // bake the indent into the
264                                            // frame itself. The 8 px
265                                            // matches the pill's internal
266                                            // left padding so buffer text
267                                            // aligns with pill text.
268                                            .frame(
269                                                egui::Frame::new()
270                                                    .inner_margin(egui::Margin::symmetric(8, 2)),
271                                            );
272                                        if let Some(ph) = self.placeholder.as_deref() {
273                                            te = te.hint_text(
274                                                egui::RichText::new(ph).color(p.text_faint),
275                                            );
276                                        }
277                                        ui.add_enabled(self.enabled, te)
278                                    })
279                                })
280                                .inner
281                            })
282                            .inner
283                    },
284                );
285
286                let edit_response = inner.inner;
287                let frame_rect = inner.response.rect;
288
289                let buffer_was_empty = state.buffer.is_empty();
290                let focused = edit_response.has_focus();
291                let lost_focus = edit_response.lost_focus();
292
293                let (enter_pressed, backspace_pressed, other_key_pressed) = ui.input(|i| {
294                    let mut any_other = false;
295                    for ev in &i.events {
296                        if let Event::Key {
297                            pressed: true, key, ..
298                        } = ev
299                        {
300                            if !matches!(key, Key::Backspace | Key::Enter) {
301                                any_other = true;
302                                break;
303                            }
304                        } else if matches!(ev, Event::Text(_)) {
305                            any_other = true;
306                            break;
307                        }
308                    }
309                    (
310                        i.key_pressed(Key::Enter),
311                        i.key_pressed(Key::Backspace),
312                        any_other,
313                    )
314                });
315
316                let separators: &[char] = if self.commit_on_space {
317                    &[',', ' ', '\t', '\n']
318                } else {
319                    &[',', '\n']
320                };
321
322                if self.enabled {
323                    // No frame-wide click sensor here: an outer click
324                    // sense at `frame_rect` would shadow the per-pill `×`
325                    // hit-tests (egui picks the topmost interact, which
326                    // is the latest-registered one). Users still focus
327                    // the editor by clicking on the editor row directly.
328
329                    // 1. Backspace on empty buffer: arm or remove.
330                    if focused && backspace_pressed {
331                        if buffer_was_empty && !self.tags.is_empty() {
332                            if state.armed {
333                                self.tags.pop();
334                                state.armed = false;
335                            } else {
336                                state.armed = true;
337                            }
338                        } else {
339                            state.armed = false;
340                        }
341                    } else if state.armed && (other_key_pressed || enter_pressed) {
342                        state.armed = false;
343                    }
344
345                    // 2. Separator characters (typed or pasted) split
346                    //    the buffer and commit each piece.
347                    if state.buffer.contains(|c: char| separators.contains(&c)) {
348                        let pieces: Vec<String> = state
349                            .buffer
350                            .split(|c: char| separators.contains(&c))
351                            .map(str::trim)
352                            .filter(|s| !s.is_empty())
353                            .map(str::to_owned)
354                            .collect();
355                        state.buffer.clear();
356                        for raw in pieces {
357                            commit_value(&raw, self.tags, self.validator.as_deref(), &mut state);
358                            if state.error.is_some() {
359                                state.buffer = raw;
360                                break;
361                            }
362                        }
363                    }
364
365                    // 3. Enter (delivered as `lost_focus`): commit the
366                    //    trimmed buffer, then re-focus so the user can
367                    //    keep typing more tags.
368                    if lost_focus && enter_pressed {
369                        let raw = state.buffer.trim().to_string();
370                        if !raw.is_empty() {
371                            commit_value(&raw, self.tags, self.validator.as_deref(), &mut state);
372                            if state.error.is_none() {
373                                state.buffer.clear();
374                            }
375                        }
376                        ui.memory_mut(|m| m.request_focus(edit_id));
377                    } else if lost_focus && !state.buffer.trim().is_empty() {
378                        // Tab / click-away: commit in-progress buffer
379                        // without re-focusing.
380                        let raw = state.buffer.trim().to_string();
381                        commit_value(&raw, self.tags, self.validator.as_deref(), &mut state);
382                        if state.error.is_none() {
383                            state.buffer.clear();
384                        }
385                    }
386
387                    // 4. Typing past an error clears it.
388                    if state.error.is_some() && !state.buffer.is_empty() && other_key_pressed {
389                        state.error = None;
390                    }
391                }
392
393                if let Some(i) = to_remove {
394                    if i < self.tags.len() {
395                        self.tags.remove(i);
396                    }
397                    state.armed = false;
398                    state.error = None;
399                }
400
401                // Resolve the frame chrome now that we know the state.
402                let frame_focused = ui.memory(|m| m.has_focus(edit_id));
403                let frame_hovered = ui.rect_contains_pointer(frame_rect);
404                let bg_fill = p.input_bg;
405                let (border_stroke_w, border_color) = if !self.enabled {
406                    (1.0, with_alpha(p.border, 160))
407                } else if state.error.is_some() {
408                    (1.5, p.danger)
409                } else if frame_focused {
410                    (1.5, p.sky)
411                } else if frame_hovered {
412                    (1.0, p.text_muted)
413                } else {
414                    (1.0, p.border)
415                };
416                let radius = CornerRadius::same(theme.control_radius as u8);
417                ui.painter().set(
418                    bg_idx,
419                    egui::Shape::rect_filled(frame_rect, radius, bg_fill),
420                );
421                ui.painter().rect_stroke(
422                    frame_rect,
423                    radius,
424                    Stroke::new(border_stroke_w, border_color),
425                    StrokeKind::Inside,
426                );
427
428                if let Some(err) = state.error.as_deref() {
429                    ui.add_space(4.0);
430                    ui.add(
431                        egui::Label::new(egui::RichText::new(err).color(p.danger).size(t.small))
432                            .wrap_mode(egui::TextWrapMode::Extend),
433                    );
434                }
435
436                edit_response
437            })
438            .inner;
439
440        ui.ctx()
441            .data_mut(|d| d.insert_temp(widget_id, state.clone()));
442
443        if let Some(label) = label_text {
444            outer.widget_info(|| WidgetInfo::labeled(WidgetType::TextEdit, self.enabled, &label));
445        }
446
447        TagInputResponse {
448            response: outer,
449            error: state.error,
450        }
451    }
452}
453
454/// The result of rendering a [`TagInput`].
455#[derive(Debug)]
456pub struct TagInputResponse {
457    /// Underlying egui [`Response`] for the inner text editor — use
458    /// `.lost_focus()`, `.has_focus()`, etc.
459    pub response: Response,
460    /// If a validator rejected the most recent commit attempt, the error
461    /// message it returned; otherwise `None`.
462    pub error: Option<String>,
463}
464
465#[derive(Clone, Default)]
466struct State {
467    buffer: String,
468    armed: bool,
469    error: Option<String>,
470}
471
472type ValidatorRef<'a> = &'a (dyn Fn(&str) -> Result<(), String> + 'a);
473
474fn commit_value(
475    raw: &str,
476    tags: &mut Vec<String>,
477    validator: Option<ValidatorRef<'_>>,
478    state: &mut State,
479) {
480    let raw = raw.trim();
481    if raw.is_empty() {
482        return;
483    }
484    if let Some(v) = validator {
485        if let Err(msg) = v(raw) {
486            state.error = Some(msg);
487            return;
488        }
489    }
490    let lower = raw.to_lowercase();
491    if tags.iter().any(|t| t.to_lowercase() == lower) {
492        // Silent dedup. The HTML mockup flashes the existing pill; in
493        // egui we just drop the duplicate.
494        state.error = None;
495        return;
496    }
497    tags.push(raw.to_owned());
498    state.error = None;
499    state.armed = false;
500}
501
502fn paint_pill(
503    ui: &mut Ui,
504    tag: &str,
505    theme: &Theme,
506    fill: Color32,
507    extra_stroke: Stroke,
508    enabled: bool,
509) -> bool {
510    let p = &theme.palette;
511    let t = &theme.typography;
512
513    let label_size = t.label;
514    let close_diam = 16.0;
515    let pad_left = 8.0;
516    let pad_right = 2.0;
517    let pad_y = 2.0;
518    let gap = 4.0;
519
520    let label_galley =
521        egui::WidgetText::from(egui::RichText::new(tag).color(p.text).size(label_size))
522            .into_galley(
523                ui,
524                Some(egui::TextWrapMode::Extend),
525                f32::INFINITY,
526                FontSelection::FontId(FontId::proportional(label_size)),
527            );
528
529    let inner_h = label_galley.size().y.max(close_diam);
530    let total = vec2(
531        pad_left + label_galley.size().x + gap + close_diam + pad_right,
532        pad_y * 2.0 + inner_h,
533    );
534    let (rect, _resp) = ui.allocate_exact_size(total, Sense::hover());
535
536    if !ui.is_rect_visible(rect) {
537        return false;
538    }
539
540    ui.painter().rect(
541        rect,
542        CornerRadius::same(4),
543        fill,
544        extra_stroke,
545        StrokeKind::Inside,
546    );
547
548    let label_pos = pos2(
549        rect.min.x + pad_left,
550        rect.center().y - label_galley.size().y * 0.5,
551    );
552    ui.painter().galley(label_pos, label_galley, p.text);
553
554    let close_rect = Rect::from_center_size(
555        pos2(rect.max.x - pad_right - close_diam * 0.5, rect.center().y),
556        Vec2::splat(close_diam),
557    );
558    let sense = if enabled {
559        Sense::click()
560    } else {
561        Sense::hover()
562    };
563    let close_resp = ui.interact(close_rect, ui.id().with(("tag_close", tag)), sense);
564
565    let close_bg = if close_resp.hovered() && enabled {
566        with_alpha(p.text, 24)
567    } else {
568        Color32::TRANSPARENT
569    };
570    ui.painter()
571        .rect_filled(close_rect, CornerRadius::same(3), close_bg);
572
573    let cross_color = if !enabled {
574        p.text_faint
575    } else if close_resp.hovered() {
576        p.text
577    } else {
578        p.text_muted
579    };
580    paint_cross(ui, close_rect, cross_color);
581
582    close_resp.clicked()
583}
584
585fn paint_cross(ui: &Ui, rect: Rect, color: Color32) {
586    let c = rect.center();
587    let s = 3.0;
588    let stroke = Stroke::new(1.5, color);
589    ui.painter()
590        .line_segment([pos2(c.x - s, c.y - s), pos2(c.x + s, c.y + s)], stroke);
591    ui.painter()
592        .line_segment([pos2(c.x - s, c.y + s), pos2(c.x + s, c.y - s)], stroke);
593}