Skip to main content

guise/input/
tags.rs

1//! `TagsInput` — a pill list with an inline editor (gpui entity).
2//!
3//! Type and press Enter (or comma) to commit a tag; committed tags render as
4//! removable pills. Tags are trimmed, non-empty and unique; Backspace in an
5//! empty query pops the last pill. Emits [`TagsInputEvent`] with the full tag
6//! list on every change.
7//!
8//! ```ignore
9//! let topics = cx.new(|cx| {
10//!     TagsInput::new(cx)
11//!         .label("Topics")
12//!         .placeholder("Add a topic…")
13//!         .max_tags(5)
14//! });
15//! cx.subscribe(&topics, |_this, _input, event: &TagsInputEvent, _cx| {
16//!     let tags: &Vec<String> = &event.0;
17//! })
18//! .detach();
19//! ```
20
21use gpui::prelude::*;
22use gpui::{
23  div, px, App, Context, Entity, EventEmitter, FocusHandle, IntoElement, KeyDownEvent,
24  SharedString, Window,
25};
26
27use super::line::{self, Line, LineEditor, LineState};
28use super::{control_metrics, edit::TextEdit, Field, KeyOutcome};
29use crate::devtools::ProbedAny;
30use crate::reactive::Signal;
31use crate::theme::{theme, ColorName, Size};
32
33/// Emitted whenever the tag list changes. Carries the full list.
34#[derive(Debug, Clone)]
35pub struct TagsInputEvent(pub Vec<String>);
36
37/// What committing a query did to the tag list.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Commit {
40  /// The tag was added; clear the query and emit.
41  Added,
42  /// Already present; clear the query, nothing to emit.
43  Duplicate,
44  /// Empty after trimming, or the list is full; leave the query alone.
45  Rejected,
46}
47
48/// Trim `raw` and append it if non-empty, unique, and under `max_tags`.
49fn commit_tag(tags: &mut Vec<String>, raw: &str, max_tags: Option<usize>) -> Commit {
50  let tag = raw.trim();
51  if tag.is_empty() {
52    return Commit::Rejected;
53  }
54  if tags.iter().any(|t| t == tag) {
55    return Commit::Duplicate;
56  }
57  if max_tags.is_some_and(|m| tags.len() >= m) {
58    return Commit::Rejected;
59  }
60  tags.push(tag.to_string());
61  Commit::Added
62}
63
64/// A tag list editor. Create with `cx.new(|cx| TagsInput::new(cx))`.
65pub struct TagsInput {
66  tags: Vec<String>,
67  query: TextEdit,
68  state: LineState,
69  focus: FocusHandle,
70  placeholder: SharedString,
71  label: Option<SharedString>,
72  description: Option<SharedString>,
73  error: Option<SharedString>,
74  max_tags: Option<usize>,
75  size: Size,
76  disabled: bool,
77}
78
79impl EventEmitter<TagsInputEvent> for TagsInput {}
80
81impl TagsInput {
82  pub fn new(cx: &mut Context<Self>) -> Self {
83    TagsInput {
84      tags: Vec::new(),
85      query: TextEdit::new(""),
86      state: LineState::new(),
87      focus: cx.focus_handle().tab_stop(true),
88      placeholder: SharedString::default(),
89      label: None,
90      description: None,
91      error: None,
92      max_tags: None,
93      size: Size::Sm,
94      disabled: false,
95    }
96  }
97
98  /// The initial tags.
99  pub fn tags<I, S>(mut self, tags: I) -> Self
100  where
101    I: IntoIterator<Item = S>,
102    S: Into<String>,
103  {
104    self.tags = tags.into_iter().map(Into::into).collect();
105    self
106  }
107
108  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
109    self.placeholder = placeholder.into();
110    self
111  }
112
113  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
114    self.label = Some(label.into());
115    self
116  }
117
118  pub fn description(mut self, description: impl Into<SharedString>) -> Self {
119    self.description = Some(description.into());
120    self
121  }
122
123  pub fn error(mut self, error: impl Into<SharedString>) -> Self {
124    self.error = Some(error.into());
125    self
126  }
127
128  /// Cap the number of tags; commits beyond it are ignored.
129  pub fn max_tags(mut self, max_tags: usize) -> Self {
130    self.max_tags = Some(max_tags);
131    self
132  }
133
134  pub fn size(mut self, size: Size) -> Self {
135    self.size = size;
136    self
137  }
138
139  pub fn disabled(mut self, disabled: bool) -> Self {
140    self.disabled = disabled;
141    self
142  }
143
144  /// The current tags.
145  pub fn tag_values(&self) -> &[String] {
146    &self.tags
147  }
148
149  /// Replace the tags programmatically (emits no event).
150  pub fn set_tags(&mut self, tags: Vec<String>, cx: &mut Context<Self>) {
151    if self.tags != tags {
152      self.tags = tags;
153      cx.notify();
154    }
155  }
156
157  /// Two-way bind this input's tags to a `Signal<Vec<String>>`. The signal
158  /// is the source of truth: the input adopts its value now, commits and
159  /// removals write back through [`Signal::set_if_changed`], and signal
160  /// writes replace the pills without emitting [`TagsInputEvent`]. Equality
161  /// guards on both directions prevent update loops.
162  pub fn bind(entity: &Entity<TagsInput>, signal: &Signal<Vec<String>>, cx: &mut App) {
163    let initial = signal.get(cx);
164    entity.update(cx, |this, cx| this.set_tags(initial, cx));
165    let sink = signal.clone();
166    cx.subscribe(entity, move |_input, event: &TagsInputEvent, cx| {
167      sink.set_if_changed(cx, event.0.clone());
168    })
169    .detach();
170    let input = entity.downgrade();
171    cx.observe(signal.entity(), move |observed, cx| {
172      let value = observed.read(cx).clone();
173      input.update(cx, |this, cx| this.set_tags(value, cx)).ok();
174    })
175    .detach();
176  }
177
178  fn remove(&mut self, index: usize, cx: &mut Context<Self>) {
179    if self.disabled || index >= self.tags.len() {
180      return;
181    }
182    self.tags.remove(index);
183    cx.emit(TagsInputEvent(self.tags.clone()));
184    cx.notify();
185  }
186
187  fn commit(&mut self, cx: &mut Context<Self>) {
188    match commit_tag(&mut self.tags, &self.query.text(), self.max_tags) {
189      Commit::Added => {
190        self.query.set_text("");
191        cx.emit(TagsInputEvent(self.tags.clone()));
192      }
193      Commit::Duplicate => self.query.set_text(""),
194      Commit::Rejected => {}
195    }
196  }
197
198  fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
199    if self.disabled {
200      return;
201    }
202    let ks = &event.keystroke;
203    // Enter and comma commit instead of editing the query. Catching the
204    // comma here, before the key is offered to the platform's input
205    // handler, is what keeps it from being typed as a character.
206    if ks.key.as_str() == "enter" || ks.key_char.as_deref() == Some(",") {
207      self.commit(cx);
208      cx.notify();
209      cx.stop_propagation();
210      return;
211    }
212    // Backspace in an empty query pops the last pill.
213    if ks.key.as_str() == "backspace" && self.query.is_empty() {
214      if self.tags.pop().is_some() {
215        cx.emit(TagsInputEvent(self.tags.clone()));
216      }
217      cx.notify();
218      cx.stop_propagation();
219      return;
220    }
221    match line::keys(self, event, window, cx) {
222      KeyOutcome::Edited => {
223        cx.notify();
224        cx.stop_propagation();
225      }
226      // Submit is handled above; Escape and the rest bubble to the host.
227      KeyOutcome::Submit | KeyOutcome::Cancel | KeyOutcome::Pass => {}
228    }
229  }
230}
231
232impl LineEditor for TagsInput {
233  fn edit(&self) -> &TextEdit {
234    &self.query
235  }
236
237  fn edit_mut(&mut self) -> &mut TextEdit {
238    &mut self.query
239  }
240
241  fn line(&self) -> &LineState {
242    &self.state
243  }
244
245  fn line_mut(&mut self) -> &mut LineState {
246    &mut self.state
247  }
248
249  fn line_focus(&self) -> &FocusHandle {
250    &self.focus
251  }
252
253  fn line_read_only(&self) -> bool {
254    self.disabled
255  }
256
257  /// A comma pasted mid-string is still a separator, so it commits rather
258  /// than landing in the query.
259  fn line_filter(&self, text: String) -> String {
260    text.replace(',', " ")
261  }
262
263  fn line_changed(&mut self, cx: &mut Context<Self>) {
264    cx.notify();
265  }
266}
267
268line::line_input_handler!(TagsInput);
269line::line_focus_builders!(TagsInput);
270
271impl Render for TagsInput {
272  fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
273    let t = theme(cx);
274    let (height, pad_x, font) = control_metrics(self.size);
275    let radius = t.radius(t.default_radius);
276    let focused = self.focus.is_focused(window) && !self.disabled;
277
278    let border = if self.error.is_some() {
279      t.color(ColorName::Red, 6)
280    } else if focused {
281      t.primary()
282    } else {
283      t.border()
284    }
285    .hsla();
286    let text_color = t.text().hsla();
287    let dimmed = t.dimmed().hsla();
288    let surface = t.surface().hsla();
289    let pill_bg = t.surface_hover().hsla();
290    let pill_h = height - 14.0;
291    let pill_font = (font - 2.0).max(10.0);
292
293    let mut field = line::wire(div().id("guise-tagsinput"), &self.focus, cx)
294      .on_key_down(cx.listener(Self::on_key))
295      .flex()
296      .flex_row()
297      .flex_wrap()
298      .items_center()
299      .gap(px(6.0))
300      .min_h(px(height))
301      .px(px(pad_x))
302      .py(px(5.0))
303      .rounded(px(radius))
304      .border_1()
305      .border_color(border)
306      .bg(surface)
307      .text_size(px(font));
308
309    for (i, tag) in self.tags.iter().enumerate() {
310      let remove = div()
311        .id(("guise-tag-remove", i))
312        .cursor_pointer()
313        .text_color(dimmed)
314        .hover(move |s| s.text_color(text_color))
315        .child(SharedString::new_static("\u{00d7}"))
316        .on_click(cx.listener(move |this, _ev, _window, cx| this.remove(i, cx)));
317      field = field.child(
318        div()
319          .id(("guise-tag", i))
320          .flex()
321          .items_center()
322          .gap(px(4.0))
323          .h(px(pill_h))
324          .px(px(8.0))
325          .rounded(px(pill_h / 2.0))
326          .bg(pill_bg)
327          .text_size(px(pill_font))
328          .text_color(text_color)
329          .child(SharedString::from(tag.clone()))
330          .child(remove),
331      );
332    }
333
334    let mut interior = Line::new(cx.entity());
335    // Only offer the placeholder while the field is genuinely empty; with
336    // pills present it would read as another tag.
337    if self.tags.is_empty() {
338      interior = interior.placeholder(self.placeholder.clone(), dimmed);
339    }
340    field = field.child(
341      div()
342        .flex_1()
343        .min_w(px(80.0))
344        .line_height(px(font * 1.3))
345        .child(interior),
346    );
347
348    let mut chrome = Field::new().child(if self.disabled {
349      field.opacity(0.6)
350    } else {
351      field
352    });
353    if let Some(label) = self.label.clone() {
354      chrome = chrome.label(label);
355    }
356    if let Some(error) = self.error.clone() {
357      chrome = chrome.error(error);
358    } else if let Some(description) = self.description.clone() {
359      chrome = chrome.description(description);
360    }
361    chrome.probe_any("TagsInput")
362  }
363}
364
365#[cfg(test)]
366mod tests {
367  use super::*;
368
369  #[test]
370  fn commits_trimmed_unique_tags() {
371    let mut tags = Vec::new();
372    assert_eq!(commit_tag(&mut tags, "  rust  ", None), Commit::Added);
373    assert_eq!(commit_tag(&mut tags, "gpui", None), Commit::Added);
374    assert_eq!(tags, vec!["rust".to_string(), "gpui".to_string()]);
375  }
376
377  #[test]
378  fn rejects_empty_and_whitespace() {
379    let mut tags = Vec::new();
380    assert_eq!(commit_tag(&mut tags, "", None), Commit::Rejected);
381    assert_eq!(commit_tag(&mut tags, "   ", None), Commit::Rejected);
382    assert!(tags.is_empty());
383  }
384
385  #[test]
386  fn detects_duplicates_after_trimming() {
387    let mut tags = vec!["rust".to_string()];
388    assert_eq!(commit_tag(&mut tags, " rust ", None), Commit::Duplicate);
389    assert_eq!(tags.len(), 1);
390  }
391
392  #[test]
393  fn respects_max_tags() {
394    let mut tags = vec!["a".to_string(), "b".to_string()];
395    assert_eq!(commit_tag(&mut tags, "c", Some(2)), Commit::Rejected);
396    assert_eq!(tags.len(), 2);
397    // A duplicate at the cap still reports Duplicate (clears the query).
398    assert_eq!(commit_tag(&mut tags, "a", Some(2)), Commit::Duplicate);
399  }
400}