1use 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
26type Validator<'a> = Box<dyn Fn(&str) -> Result<(), String> + 'a>;
29
30#[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 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 pub fn label(mut self, text: impl Into<WidgetText>) -> Self {
115 self.label = Some(text.into());
116 self
117 }
118
119 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
121 self.placeholder = Some(text.into());
122 self
123 }
124
125 pub fn accent(mut self, accent: Accent) -> Self {
127 self.accent = accent;
128 self
129 }
130
131 pub fn enabled(mut self, enabled: bool) -> Self {
134 self.enabled = enabled;
135 self
136 }
137
138 pub fn commit_on_space(mut self, on: bool) -> Self {
142 self.commit_on_space = on;
143 self
144 }
145
146 pub fn desired_width(mut self, width: f32) -> Self {
149 self.desired_width = Some(width);
150 self
151 }
152
153 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 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 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 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 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 .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 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 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 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 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 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 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#[derive(Debug)]
456pub struct TagInputResponse {
457 pub response: Response,
460 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 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}