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