Skip to main content

gpui_kit/controls/
tag_input.rs

1//! Tokenised entry: the tags a typist has assembled, and a field to add one.
2//!
3//! The set belongs to the caller. Adding and removing are reported, never
4//! applied, so a host that refuses an addition simply does not add it. What
5//! the control refuses on its own — a duplicate, a set already at its limit —
6//! it refuses out loud: a keystroke that vanishes without a word is
7//! indistinguishable from a broken field.
8
9use gpui::{
10    App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
11    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
12    Window, div, prelude::FluentBuilder, px,
13};
14use gpui_kit_semantics::{NodeSpec, Role, Semantic};
15use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
16
17use crate::controls::field::{FieldState, field_shell};
18use crate::controls::input::{TextInput, TextInputEvent};
19use crate::display::tag::Tag;
20use crate::foundation::{
21    Disableable, Ident, Selectable, Sizable, StyledExt, text as foundation_text,
22};
23use crate::strings::{ActiveStrings, StringKey};
24
25/// What a tag field reports. The owner decides what any of it means.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TagInputEvent {
28    /// A value the typist asked to add.
29    Added(SharedString),
30    /// A tag the typist asked to remove.
31    Removed(SharedString),
32    /// The value is already in the set. Reported rather than dropped, so a
33    /// host can say so where the typist is looking.
34    Duplicate(SharedString),
35    /// The set is already as large as it was allowed to be.
36    Refused(SharedString),
37}
38
39impl EventEmitter<TagInputEvent> for TagInput {}
40
41/// A field that collects a set of short tokens.
42///
43/// A duplicate and a full field are refusals, shown where the typist is
44/// looking and with the typed text left in place, because a token the host
45/// will not take is not a token that silently vanished.
46pub struct TagInput {
47    ident: Ident,
48    focus_handle: FocusHandle,
49    field: Entity<TextInput>,
50    tags: Vec<SharedString>,
51    placeholder: Option<SharedString>,
52    max: Option<usize>,
53    size: ControlSize,
54    disabled: bool,
55    invalid: bool,
56    /// The tag backspace has singled out, which is not a tag that is gone.
57    targeted: Option<SharedString>,
58    /// What the control last refused, in the words it will show.
59    refusal: Option<SharedString>,
60    /// Held so the field subscription lives as long as the control does.
61    _subscriptions: Vec<Subscription>,
62}
63
64impl std::fmt::Debug for TagInput {
65    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        formatter
67            .debug_struct("TagInput")
68            .field("ident", &self.ident)
69            .field("tags", &self.tags.len())
70            .field("max", &self.max)
71            .field("targeted", &self.targeted)
72            .field("disabled", &self.disabled)
73            .finish()
74    }
75}
76
77impl TagInput {
78    pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
79        let ident = ident.into();
80        let field = cx.new(|cx| TextInput::new(ident.child("field"), window, cx).bare(true));
81        let subscription = cx.subscribe(&field, |tags, _field, event, cx| match event {
82            TextInputEvent::Change(text) => tags.on_change(text.clone(), cx),
83            TextInputEvent::Submit => tags.commit(cx),
84            TextInputEvent::BackspaceAtStart => tags.backspace(cx),
85            TextInputEvent::Cancel => tags.untarget(cx),
86            _ => {}
87        });
88
89        Self {
90            ident,
91            focus_handle: cx.focus_handle(),
92            field,
93            tags: Vec::new(),
94            placeholder: None,
95            max: None,
96            size: ControlSize::Md,
97            disabled: false,
98            invalid: false,
99            targeted: None,
100            refusal: None,
101            _subscriptions: vec![subscription],
102        }
103    }
104
105    pub fn tags(mut self, tags: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
106        self.tags = tags.into_iter().map(Into::into).collect();
107        self
108    }
109
110    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
111        self.placeholder = Some(placeholder.into());
112        self
113    }
114
115    /// How many tags the set may hold. Reaching it is reported as a refusal,
116    /// not enforced by swallowing what was typed.
117    pub fn max(mut self, max: usize) -> Self {
118        self.max = Some(max);
119        self
120    }
121
122    pub fn invalid(mut self, invalid: bool) -> Self {
123        self.invalid = invalid;
124        self
125    }
126
127    /// Replaces the set from the host side, which is how an addition or a
128    /// removal actually takes effect.
129    pub fn set_tags(&mut self, tags: Vec<SharedString>, cx: &mut Context<Self>) {
130        self.tags = tags;
131        self.targeted = None;
132        self.refusal = None;
133        cx.notify();
134    }
135
136    pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
137        self.disabled = disabled;
138        self.field
139            .update(cx, |field, cx| field.set_disabled(disabled, cx));
140        cx.notify();
141    }
142
143    pub fn current(&self) -> &[SharedString] {
144        &self.tags
145    }
146
147    pub fn field(&self) -> &Entity<TextInput> {
148        &self.field
149    }
150
151    /// The tag the next backspace would remove, if there is one.
152    pub fn targeted(&self) -> Option<&SharedString> {
153        self.targeted.as_ref()
154    }
155
156    /// What the control last refused, in the words it shows.
157    pub fn refusal(&self) -> Option<&SharedString> {
158        self.refusal.as_ref()
159    }
160
161    fn is_full(&self) -> bool {
162        self.max.is_some_and(|max| self.tags.len() >= max)
163    }
164
165    fn clear_field(&mut self, cx: &mut Context<Self>) {
166        self.field
167            .update(cx, |field, cx| field.set_text_quietly("", cx));
168    }
169
170    /// Commits what is typed, up to but not including a trailing separator.
171    ///
172    /// A comma is a separator rather than a character here, so typing one
173    /// commits what came before it instead of ending up inside a tag.
174    fn on_change(&mut self, text: SharedString, cx: &mut Context<Self>) {
175        // Anything typed moves the keyboard off a targeted tag: the typist is
176        // adding, not removing.
177        if !text.is_empty() {
178            self.targeted = None;
179        }
180        if text.ends_with(',') {
181            let value = SharedString::from(text.trim_end_matches(',').to_string());
182            self.add(value, cx);
183        }
184        cx.notify();
185    }
186
187    fn commit(&mut self, cx: &mut Context<Self>) {
188        let typed = self.field.read(cx).value().clone();
189        self.add(typed, cx);
190    }
191
192    fn add(&mut self, value: SharedString, cx: &mut Context<Self>) {
193        let trimmed = value.trim();
194        if trimmed.is_empty() {
195            self.clear_field(cx);
196            return;
197        }
198        let value = SharedString::from(trimmed.to_string());
199
200        if self.tags.iter().any(|tag| tag == &value) {
201            self.refusal = Some(cx.strings().format(StringKey::TagInputDuplicate, &[&value]));
202            cx.emit(TagInputEvent::Duplicate(value));
203            cx.notify();
204            return;
205        }
206        if self.is_full() {
207            let max = self.max.unwrap_or_default();
208            self.refusal = Some(
209                cx.strings()
210                    .format(StringKey::TagInputFull, &[&max.to_string(), &value]),
211            );
212            cx.emit(TagInputEvent::Refused(value));
213            cx.notify();
214            return;
215        }
216
217        self.refusal = None;
218        self.clear_field(cx);
219        cx.emit(TagInputEvent::Added(value));
220        cx.notify();
221    }
222
223    fn remove(&mut self, value: SharedString, cx: &mut Context<Self>) {
224        self.targeted = None;
225        self.refusal = None;
226        cx.emit(TagInputEvent::Removed(value));
227        cx.notify();
228    }
229
230    /// Backspace in an empty field. The first press singles out the last tag
231    /// and the second removes it, because a deletion nobody saw coming is a
232    /// deletion nobody agreed to.
233    fn backspace(&mut self, cx: &mut Context<Self>) {
234        if self.disabled || !self.field.read(cx).value().is_empty() {
235            return;
236        }
237        match self.targeted.clone() {
238            Some(value) if self.tags.iter().any(|tag| tag == &value) => self.remove(value, cx),
239            _ => {
240                self.targeted = self.tags.last().cloned();
241                cx.notify();
242            }
243        }
244    }
245
246    /// Lets the singled-out tag go without removing it.
247    fn untarget(&mut self, cx: &mut Context<Self>) {
248        if self.targeted.take().is_some() {
249            cx.notify();
250        }
251    }
252}
253
254impl Disableable for TagInput {
255    fn disabled(mut self, disabled: bool) -> Self {
256        self.disabled = disabled;
257        self
258    }
259}
260
261impl Sizable for TagInput {
262    fn control_size(mut self, size: ControlSize) -> Self {
263        self.size = size;
264        self
265    }
266}
267
268impl Focusable for TagInput {
269    fn focus_handle(&self, _cx: &App) -> FocusHandle {
270        self.focus_handle.clone()
271    }
272}
273
274impl Render for TagInput {
275    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
276        let theme = cx.theme().clone();
277        if self.field.read(cx).placeholder_text().is_empty() {
278            let placeholder = self
279                .placeholder
280                .clone()
281                .unwrap_or_else(|| cx.strings().text(StringKey::TagInputPlaceholder));
282            self.field
283                .update(cx, |field, cx| field.set_placeholder(placeholder, cx));
284        }
285        if self.disabled != self.field.read(cx).is_disabled() {
286            let disabled = self.disabled;
287            self.field
288                .update(cx, |field, cx| field.set_disabled(disabled, cx));
289        }
290
291        let focused = self.field.read(cx).focus_handle(cx).is_focused(window);
292        let invalid = self.invalid || self.refusal.is_some();
293        let full = self.is_full();
294
295        let control = cx.entity().downgrade();
296        let tags = self
297            .tags
298            .iter()
299            .map(|tag_value| {
300                let ident = self.ident.child(tag_value.as_ref());
301                let removing = tag_value.clone();
302                let control = control.clone();
303                Tag::new(ident, tag_value.clone())
304                    .selected(self.targeted.as_ref() == Some(tag_value))
305                    .disabled(self.disabled)
306                    .on_remove(move |_window, cx| {
307                        control
308                            .update(cx, |tags, cx| tags.remove(removing.clone(), cx))
309                            .ok();
310                    })
311            })
312            .collect::<Vec<_>>();
313
314        let count = self.tags.len();
315        let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Group)
316            .disabled(self.disabled)
317            .invalid(invalid)
318            .focus(&self.field.read(cx).focus_handle(cx))
319            .value(SharedString::from(match self.max {
320                Some(max) => format!("{count} of {max}"),
321                None => count.to_string(),
322            }));
323        if let Some(refusal) = self.refusal.clone() {
324            spec = spec.text(refusal);
325        }
326
327        div()
328            .id(self.ident.element_id())
329            .column()
330            .w_full()
331            .gap(px(theme.space(Space::Xs)))
332            .track_focus(&self.focus_handle)
333            .child(
334                field_shell(
335                    &theme,
336                    self.size,
337                    FieldState::default()
338                        .focused(focused)
339                        .invalid(invalid)
340                        .disabled(self.disabled),
341                )
342                .flex_wrap()
343                .py(px(theme.space(Space::Xs)))
344                .gap(px(theme.space(Space::Xs)))
345                .children(tags)
346                .child(div().flex_1().min_w(px(80.0)).child(self.field.clone())),
347            )
348            .children(self.refusal.clone().map(|refusal| {
349                foundation_text(&theme, TypeScale::Caption, refusal.clone())
350                    .text_color(theme.colors.danger)
351                    .semantic_in(
352                        cx,
353                        NodeSpec::new(self.ident.child("refusal").semantic_id(), Role::Status)
354                            .parent(self.ident.semantic_id())
355                            .invalid(true)
356                            .text(refusal),
357                    )
358            }))
359            .when(full && self.refusal.is_none(), |element| {
360                element.child(
361                    foundation_text(
362                        &theme,
363                        TypeScale::Caption,
364                        cx.strings().format(
365                            StringKey::TagInputUsed,
366                            &[
367                                &count.to_string(),
368                                &self.max.unwrap_or_default().to_string(),
369                            ],
370                        ),
371                    )
372                    .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
373                )
374            })
375            .semantic_in(cx, spec)
376    }
377}