Skip to main content

elegance/
removable_chip.rs

1//! A single editable, removable chip: a bordered inline text edit with an
2//! optional non-editable prefix and an `×` close button.
3//!
4//! Use this when you have one optional value that should appear inline among
5//! other content (e.g., an inline filter pill, a path-segment chip, an
6//! editable tag in a single-tag form) and the user can clear it by clicking
7//! `×` or pressing Escape on an empty input. For multi-value tag inputs,
8//! see [`TagInput`](crate::TagInput).
9
10use std::hash::Hash;
11
12use egui::{
13    pos2, vec2, Color32, CornerRadius, FontId, FontSelection, Id, Rect, Response, Sense, Shape,
14    Stroke, StrokeKind, TextEdit, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
15};
16
17use crate::theme::{themed_input_visuals, with_alpha, with_themed_visuals, Theme};
18use crate::Accent;
19
20/// A bordered inline text input with an `×` close button, bound to a single
21/// `String`.
22///
23/// ```no_run
24/// # use elegance::RemovableChip;
25/// # egui::__run_test_ui(|ui| {
26/// let mut suffix = String::from("run-1");
27/// let resp = RemovableChip::new(&mut suffix)
28///     .prefix("_")
29///     .placeholder("run-1")
30///     .show(ui);
31/// if resp.removed {
32///     // caller drops the field
33/// }
34/// # });
35/// ```
36///
37/// The chip auto-sizes its editor to fit the current text, clamped to
38/// [`auto_size`](Self::auto_size). The `removed` flag in the returned
39/// [`RemovableChipResponse`] is set when the user clicks `×` or presses
40/// Escape on an empty input; the caller decides whether to actually clear
41/// or drop the binding.
42#[must_use = "Call `.show(ui)` to render the chip."]
43pub struct RemovableChip<'a> {
44    text: &'a mut String,
45    prefix: Option<WidgetText>,
46    placeholder: Option<&'a str>,
47    accent: Accent,
48    enabled: bool,
49    min_width: f32,
50    max_width: f32,
51    id_salt: Option<Id>,
52    focus_on_render: bool,
53    close_on_empty_blur: bool,
54}
55
56impl<'a> std::fmt::Debug for RemovableChip<'a> {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("RemovableChip")
59            .field("prefix", &self.prefix.as_ref().map(|w| w.text()))
60            .field("placeholder", &self.placeholder)
61            .field("accent", &self.accent)
62            .field("enabled", &self.enabled)
63            .field("min_width", &self.min_width)
64            .field("max_width", &self.max_width)
65            .field("focus_on_render", &self.focus_on_render)
66            .field("close_on_empty_blur", &self.close_on_empty_blur)
67            .finish()
68    }
69}
70
71impl<'a> RemovableChip<'a> {
72    /// Create a chip bound to `text`. The chip's value mirrors this `String`.
73    pub fn new(text: &'a mut String) -> Self {
74        Self {
75            text,
76            prefix: None,
77            placeholder: None,
78            accent: Accent::Sky,
79            enabled: true,
80            min_width: 50.0,
81            max_width: 240.0,
82            id_salt: None,
83            focus_on_render: false,
84            close_on_empty_blur: false,
85        }
86    }
87
88    /// Show non-editable text inside the chip, before the input. Useful for
89    /// leading separators (e.g. `"_"` for a path-suffix chip) or for fixed
90    /// labels that read as part of the value but aren't part of the binding.
91    pub fn prefix(mut self, text: impl Into<WidgetText>) -> Self {
92        self.prefix = Some(text.into());
93        self
94    }
95
96    /// Placeholder text shown when the input is empty.
97    pub fn placeholder(mut self, text: &'a str) -> Self {
98        self.placeholder = Some(text);
99        self
100    }
101
102    /// Border / focus accent colour. Default: [`Accent::Sky`].
103    pub fn accent(mut self, accent: Accent) -> Self {
104        self.accent = accent;
105        self
106    }
107
108    /// Disable the chip. Disabled chips ignore typing and clicks on `×`.
109    /// Default: enabled.
110    pub fn enabled(mut self, enabled: bool) -> Self {
111        self.enabled = enabled;
112        self
113    }
114
115    /// Minimum and maximum width (points) for the editor portion. The chip
116    /// measures the current text and sizes the editor within this range.
117    /// Default: `50.0..=240.0`.
118    pub fn auto_size(mut self, range: std::ops::RangeInclusive<f32>) -> Self {
119        self.min_width = *range.start();
120        self.max_width = *range.end();
121        self
122    }
123
124    /// Stable id salt. Useful when several chips share a layout, or when
125    /// you need to address the chip's state across frames.
126    pub fn id_salt(mut self, id: impl Hash) -> Self {
127        self.id_salt = Some(Id::new(id));
128        self
129    }
130
131    /// Request focus for the chip's TextEdit on this render. Pass `true`
132    /// on the frame the chip first appears (e.g. just after the user
133    /// clicked an "+ add" button to surface it) so the input is focused
134    /// the moment it shows up; pass `false` on subsequent frames so the
135    /// user can click out and the focus request doesn't keep stealing it
136    /// back. The chip calls `request_focus` on its TextEdit's id before
137    /// the widget is added, so focus lands on the very first frame the
138    /// chip is visible.
139    pub fn focus(mut self, request: bool) -> Self {
140        self.focus_on_render = request;
141        self
142    }
143
144    /// Auto-fire `removed` when the editor loses focus while empty. Use
145    /// this for "+ add" affordances where leaving the chip empty is
146    /// equivalent to deciding not to add the field at all: the caller
147    /// then drops the binding and the affordance reverts to its
148    /// pre-click state. Pairs naturally with [`focus`](Self::focus).
149    /// Default: `false`.
150    pub fn close_on_empty_blur(mut self, on: bool) -> Self {
151        self.close_on_empty_blur = on;
152        self
153    }
154
155    /// Render the chip and return its response.
156    pub fn show(self, ui: &mut Ui) -> RemovableChipResponse {
157        let theme = Theme::current(ui.ctx());
158        let p = &theme.palette;
159        let t = &theme.typography;
160
161        let id_salt = self.id_salt.unwrap_or_else(|| Id::new(ui.next_auto_id()));
162        let edit_id = ui.make_persistent_id(id_salt);
163
164        // Honour `focus(true)` before the TextEdit is added so the
165        // widget picks up focus the same frame it appears (request_focus
166        // applied after add only takes effect the following frame).
167        if self.focus_on_render && self.enabled {
168            ui.memory_mut(|m| m.request_focus(edit_id));
169        }
170
171        let pad_x = 6.0;
172        // Vertical padding chosen so the chip renders at the same total
173        // height as `Button::size(ButtonSize::Small)` (≈22 pt). Anything
174        // smaller and the chip sits 2 px shorter than its row neighbours,
175        // which reads as a layout bug rather than a stylistic choice.
176        let pad_y = 3.0;
177        let close_diam = 16.0;
178        let gap = 4.0;
179
180        // Reserve the background shape index now so we can paint the fill
181        // and border under the inner content once focus is known.
182        let bg_idx = ui.painter().add(Shape::Noop);
183
184        // Auto-size the editor by measuring the current text (or
185        // placeholder when empty) at body-font size and clamping into the
186        // user-supplied range.
187        let measure_text = if self.text.is_empty() {
188            self.placeholder.unwrap_or("")
189        } else {
190            self.text.as_str()
191        };
192        let measured = WidgetText::from(egui::RichText::new(measure_text).size(t.body))
193            .into_galley(
194                ui,
195                Some(egui::TextWrapMode::Extend),
196                f32::INFINITY,
197                FontSelection::FontId(FontId::proportional(t.body)),
198            );
199        let editor_w = (measured.size().x + 6.0).clamp(self.min_width, self.max_width);
200
201        let mut removed = false;
202
203        let inner = ui.horizontal(|ui| {
204            ui.spacing_mut().item_spacing = vec2(gap, 0.0);
205            ui.add_space(pad_x);
206
207            if let Some(prefix) = &self.prefix {
208                let rich = egui::RichText::new(prefix.text())
209                    .color(p.text_faint)
210                    .size(t.body);
211                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
212            }
213
214            // The editor borrows the chip's outer chrome, so strip its
215            // per-state strokes and bg fill.
216            let edit_response = with_themed_visuals(ui, |ui| {
217                let v = ui.visuals_mut();
218                themed_input_visuals(v, &theme, Color32::TRANSPARENT);
219                v.extreme_bg_color = Color32::TRANSPARENT;
220                for w in [
221                    &mut v.widgets.inactive,
222                    &mut v.widgets.hovered,
223                    &mut v.widgets.active,
224                    &mut v.widgets.open,
225                ] {
226                    w.bg_stroke = Stroke::NONE;
227                }
228                v.selection.bg_fill = with_alpha(p.sky, 90);
229                v.selection.stroke = Stroke::new(1.0, p.sky);
230
231                let mut te = TextEdit::singleline(self.text)
232                    .id(edit_id)
233                    .font(FontSelection::FontId(FontId::proportional(t.body)))
234                    .text_color(p.text)
235                    .desired_width(editor_w)
236                    .frame(
237                        egui::Frame::new().inner_margin(egui::Margin::symmetric(0, pad_y as i8)),
238                    );
239                if let Some(ph) = self.placeholder {
240                    te = te.hint_text(egui::RichText::new(ph).color(p.text_faint));
241                }
242                ui.add_enabled(self.enabled, te)
243            });
244
245            // Reserve the (×) button's footprint now so the chip's
246            // overall layout is stable whether the cross is currently
247            // painted or hidden. Defer the paint + click handling until
248            // after we know the chip's outer rect (and therefore
249            // hover-on-chip), so the cross can be hidden when the chip
250            // is at rest and revealed on hover or focus.
251            let close_size = Vec2::splat(close_diam);
252            let sense = if self.enabled {
253                Sense::click()
254            } else {
255                Sense::hover()
256            };
257            let (close_rect, close_resp) = ui.allocate_exact_size(close_size, sense);
258
259            ui.add_space((pad_x - gap).max(0.0));
260
261            (edit_response, close_rect, close_resp)
262        });
263
264        let (edit_response, close_rect, close_resp) = inner.inner;
265        let frame_rect = inner.response.rect;
266
267        // The cross only renders when the chip is "active": editor has
268        // focus, or the pointer is anywhere over the chip's frame. At
269        // rest the chip is just a value pill with no affordance noise.
270        let frame_hovered = ui.rect_contains_pointer(frame_rect);
271        let chip_active = edit_response.has_focus() || frame_hovered;
272        if self.enabled && chip_active {
273            let close_bg = if close_resp.hovered() {
274                with_alpha(p.danger, 32)
275            } else {
276                Color32::TRANSPARENT
277            };
278            ui.painter()
279                .rect_filled(close_rect, CornerRadius::same(3), close_bg);
280            let cross_color = if close_resp.hovered() {
281                p.danger
282            } else {
283                p.text_muted
284            };
285            paint_cross(ui, close_rect, cross_color);
286
287            if close_resp.clicked() {
288                removed = true;
289            }
290        }
291
292        // Escape on an empty editor signals "remove" to the caller.
293        if self.enabled
294            && edit_response.has_focus()
295            && self.text.is_empty()
296            && ui.input(|i| i.key_pressed(egui::Key::Escape))
297        {
298            removed = true;
299        }
300
301        // Empty editor losing focus also fires `removed` when the caller
302        // opted in. Lets a freshly-surfaced chip auto-close if the user
303        // clicks elsewhere without typing anything.
304        if self.close_on_empty_blur
305            && self.enabled
306            && edit_response.lost_focus()
307            && self.text.trim().is_empty()
308        {
309            removed = true;
310        }
311
312        // Paint the chip's frame underneath everything. Reuse the
313        // already-computed `frame_hovered` from the close-button gating
314        // above.
315        let frame_focused = ui.memory(|m| m.has_focus(edit_id));
316        let bg_fill = p.input_bg;
317        let (border_w, border_color) = if !self.enabled {
318            (1.0, with_alpha(p.border, 160))
319        } else if frame_focused {
320            (1.5, p.accent_fill(self.accent))
321        } else if frame_hovered {
322            (1.0, p.text_muted)
323        } else {
324            (1.0, p.border)
325        };
326        let radius = CornerRadius::same(theme.control_radius as u8);
327        ui.painter()
328            .set(bg_idx, Shape::rect_filled(frame_rect, radius, bg_fill));
329        ui.painter().rect_stroke(
330            frame_rect,
331            radius,
332            Stroke::new(border_w, border_color),
333            StrokeKind::Inside,
334        );
335
336        // Speak the placeholder (or "Removable chip" when none is set) as
337        // the field label. The current text is announced separately by the
338        // OS, so the widget label should describe the field's purpose.
339        let label_for_a11y = self
340            .placeholder
341            .map(str::to_owned)
342            .unwrap_or_else(|| "Removable chip".to_string());
343        let response = inner.response;
344        response.widget_info(|| {
345            WidgetInfo::labeled(WidgetType::TextEdit, self.enabled, &label_for_a11y)
346        });
347
348        RemovableChipResponse { response, removed }
349    }
350}
351
352/// The result of rendering a [`RemovableChip`].
353#[derive(Debug)]
354pub struct RemovableChipResponse {
355    /// Outer [`Response`] covering the whole chip rect. Use this to react
356    /// to hover, click-outside, etc.
357    pub response: Response,
358    /// `true` when the user clicked the `×` button or pressed Escape on
359    /// an empty editor. The caller decides whether to clear the binding,
360    /// drop the chip, or otherwise react.
361    pub removed: bool,
362}
363
364fn paint_cross(ui: &Ui, rect: Rect, color: Color32) {
365    let c = rect.center();
366    let s = 3.0;
367    let stroke = Stroke::new(1.5, color);
368    ui.painter()
369        .line_segment([pos2(c.x - s, c.y - s), pos2(c.x + s, c.y + s)], stroke);
370    ui.painter()
371        .line_segment([pos2(c.x - s, c.y + s), pos2(c.x + s, c.y - s)], stroke);
372}