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}
53
54impl<'a> std::fmt::Debug for RemovableChip<'a> {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        f.debug_struct("RemovableChip")
57            .field("prefix", &self.prefix.as_ref().map(|w| w.text()))
58            .field("placeholder", &self.placeholder)
59            .field("accent", &self.accent)
60            .field("enabled", &self.enabled)
61            .field("min_width", &self.min_width)
62            .field("max_width", &self.max_width)
63            .finish()
64    }
65}
66
67impl<'a> RemovableChip<'a> {
68    /// Create a chip bound to `text`. The chip's value mirrors this `String`.
69    pub fn new(text: &'a mut String) -> Self {
70        Self {
71            text,
72            prefix: None,
73            placeholder: None,
74            accent: Accent::Sky,
75            enabled: true,
76            min_width: 50.0,
77            max_width: 240.0,
78            id_salt: None,
79        }
80    }
81
82    /// Show non-editable text inside the chip, before the input. Useful for
83    /// leading separators (e.g. `"_"` for a path-suffix chip) or for fixed
84    /// labels that read as part of the value but aren't part of the binding.
85    pub fn prefix(mut self, text: impl Into<WidgetText>) -> Self {
86        self.prefix = Some(text.into());
87        self
88    }
89
90    /// Placeholder text shown when the input is empty.
91    pub fn placeholder(mut self, text: &'a str) -> Self {
92        self.placeholder = Some(text);
93        self
94    }
95
96    /// Border / focus accent colour. Default: [`Accent::Sky`].
97    pub fn accent(mut self, accent: Accent) -> Self {
98        self.accent = accent;
99        self
100    }
101
102    /// Disable the chip. Disabled chips ignore typing and clicks on `×`.
103    /// Default: enabled.
104    pub fn enabled(mut self, enabled: bool) -> Self {
105        self.enabled = enabled;
106        self
107    }
108
109    /// Minimum and maximum width (points) for the editor portion. The chip
110    /// measures the current text and sizes the editor within this range.
111    /// Default: `50.0..=240.0`.
112    pub fn auto_size(mut self, range: std::ops::RangeInclusive<f32>) -> Self {
113        self.min_width = *range.start();
114        self.max_width = *range.end();
115        self
116    }
117
118    /// Stable id salt. Useful when several chips share a layout, or when
119    /// you need to address the chip's state across frames.
120    pub fn id_salt(mut self, id: impl Hash) -> Self {
121        self.id_salt = Some(Id::new(id));
122        self
123    }
124
125    /// Render the chip and return its response.
126    pub fn show(self, ui: &mut Ui) -> RemovableChipResponse {
127        let theme = Theme::current(ui.ctx());
128        let p = &theme.palette;
129        let t = &theme.typography;
130
131        let id_salt = self.id_salt.unwrap_or_else(|| Id::new(ui.next_auto_id()));
132        let edit_id = ui.make_persistent_id(id_salt);
133
134        let pad_x = 6.0;
135        let pad_y = 2.0;
136        let close_diam = 16.0;
137        let gap = 4.0;
138
139        // Reserve the background shape index now so we can paint the fill
140        // and border under the inner content once focus is known.
141        let bg_idx = ui.painter().add(Shape::Noop);
142
143        // Auto-size the editor by measuring the current text (or
144        // placeholder when empty) at body-font size and clamping into the
145        // user-supplied range.
146        let measure_text = if self.text.is_empty() {
147            self.placeholder.unwrap_or("")
148        } else {
149            self.text.as_str()
150        };
151        let measured = WidgetText::from(egui::RichText::new(measure_text).size(t.body))
152            .into_galley(
153                ui,
154                Some(egui::TextWrapMode::Extend),
155                f32::INFINITY,
156                FontSelection::FontId(FontId::proportional(t.body)),
157            );
158        let editor_w = (measured.size().x + 6.0).clamp(self.min_width, self.max_width);
159
160        let mut removed = false;
161
162        let inner = ui.horizontal(|ui| {
163            ui.spacing_mut().item_spacing = vec2(gap, 0.0);
164            ui.add_space(pad_x);
165
166            if let Some(prefix) = &self.prefix {
167                let rich = egui::RichText::new(prefix.text())
168                    .color(p.text_faint)
169                    .size(t.body);
170                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
171            }
172
173            // The editor borrows the chip's outer chrome, so strip its
174            // per-state strokes and bg fill.
175            let edit_response = with_themed_visuals(ui, |ui| {
176                let v = ui.visuals_mut();
177                themed_input_visuals(v, &theme, Color32::TRANSPARENT);
178                v.extreme_bg_color = Color32::TRANSPARENT;
179                for w in [
180                    &mut v.widgets.inactive,
181                    &mut v.widgets.hovered,
182                    &mut v.widgets.active,
183                    &mut v.widgets.open,
184                ] {
185                    w.bg_stroke = Stroke::NONE;
186                }
187                v.selection.bg_fill = with_alpha(p.sky, 90);
188                v.selection.stroke = Stroke::new(1.0, p.sky);
189
190                let mut te = TextEdit::singleline(self.text)
191                    .id(edit_id)
192                    .font(FontSelection::FontId(FontId::proportional(t.body)))
193                    .text_color(p.text)
194                    .desired_width(editor_w)
195                    .frame(
196                        egui::Frame::new().inner_margin(egui::Margin::symmetric(0, pad_y as i8)),
197                    );
198                if let Some(ph) = self.placeholder {
199                    te = te.hint_text(egui::RichText::new(ph).color(p.text_faint));
200                }
201                ui.add_enabled(self.enabled, te)
202            });
203
204            // Close (×) button: a small interact area with a hand-drawn
205            // cross. Hovered state tints the bg with the danger colour to
206            // signal "click to remove."
207            let close_size = Vec2::splat(close_diam);
208            let sense = if self.enabled {
209                Sense::click()
210            } else {
211                Sense::hover()
212            };
213            let (close_rect, close_resp) = ui.allocate_exact_size(close_size, sense);
214
215            let close_bg = if close_resp.hovered() && self.enabled {
216                with_alpha(p.danger, 32)
217            } else {
218                Color32::TRANSPARENT
219            };
220            ui.painter()
221                .rect_filled(close_rect, CornerRadius::same(3), close_bg);
222
223            let cross_color = if !self.enabled {
224                p.text_faint
225            } else if close_resp.hovered() {
226                p.danger
227            } else {
228                p.text_muted
229            };
230            paint_cross(ui, close_rect, cross_color);
231
232            if close_resp.clicked() {
233                removed = true;
234            }
235
236            ui.add_space((pad_x - gap).max(0.0));
237
238            edit_response
239        });
240
241        let edit_response = inner.inner;
242        let frame_rect = inner.response.rect;
243
244        // Escape on an empty editor signals "remove" to the caller.
245        if self.enabled
246            && edit_response.has_focus()
247            && self.text.is_empty()
248            && ui.input(|i| i.key_pressed(egui::Key::Escape))
249        {
250            removed = true;
251        }
252
253        // Paint the chip's frame underneath everything.
254        let frame_focused = ui.memory(|m| m.has_focus(edit_id));
255        let frame_hovered = ui.rect_contains_pointer(frame_rect);
256        let bg_fill = p.input_bg;
257        let (border_w, border_color) = if !self.enabled {
258            (1.0, with_alpha(p.border, 160))
259        } else if frame_focused {
260            (1.5, p.accent_fill(self.accent))
261        } else if frame_hovered {
262            (1.0, p.text_muted)
263        } else {
264            (1.0, p.border)
265        };
266        let radius = CornerRadius::same(theme.control_radius as u8);
267        ui.painter()
268            .set(bg_idx, Shape::rect_filled(frame_rect, radius, bg_fill));
269        ui.painter().rect_stroke(
270            frame_rect,
271            radius,
272            Stroke::new(border_w, border_color),
273            StrokeKind::Inside,
274        );
275
276        // Speak the placeholder (or "Removable chip" when none is set) as
277        // the field label. The current text is announced separately by the
278        // OS, so the widget label should describe the field's purpose.
279        let label_for_a11y = self
280            .placeholder
281            .map(str::to_owned)
282            .unwrap_or_else(|| "Removable chip".to_string());
283        let response = inner.response;
284        response.widget_info(|| {
285            WidgetInfo::labeled(WidgetType::TextEdit, self.enabled, &label_for_a11y)
286        });
287
288        RemovableChipResponse { response, removed }
289    }
290}
291
292/// The result of rendering a [`RemovableChip`].
293#[derive(Debug)]
294pub struct RemovableChipResponse {
295    /// Outer [`Response`] covering the whole chip rect. Use this to react
296    /// to hover, click-outside, etc.
297    pub response: Response,
298    /// `true` when the user clicked the `×` button or pressed Escape on
299    /// an empty editor. The caller decides whether to clear the binding,
300    /// drop the chip, or otherwise react.
301    pub removed: bool,
302}
303
304fn paint_cross(ui: &Ui, rect: Rect, color: Color32) {
305    let c = rect.center();
306    let s = 3.0;
307    let stroke = Stroke::new(1.5, color);
308    ui.painter()
309        .line_segment([pos2(c.x - s, c.y - s), pos2(c.x + s, c.y + s)], stroke);
310    ui.painter()
311        .line_segment([pos2(c.x - s, c.y + s), pos2(c.x + s, c.y - s)], stroke);
312}