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}