1use 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#[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 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 pub fn prefix(mut self, text: impl Into<WidgetText>) -> Self {
86 self.prefix = Some(text.into());
87 self
88 }
89
90 pub fn placeholder(mut self, text: &'a str) -> Self {
92 self.placeholder = Some(text);
93 self
94 }
95
96 pub fn accent(mut self, accent: Accent) -> Self {
98 self.accent = accent;
99 self
100 }
101
102 pub fn enabled(mut self, enabled: bool) -> Self {
105 self.enabled = enabled;
106 self
107 }
108
109 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 pub fn id_salt(mut self, id: impl Hash) -> Self {
121 self.id_salt = Some(Id::new(id));
122 self
123 }
124
125 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 let bg_idx = ui.painter().add(Shape::Noop);
142
143 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 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 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 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 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 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#[derive(Debug)]
294pub struct RemovableChipResponse {
295 pub response: Response,
298 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}