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}