1use 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#[derive(Debug, Clone)]
33pub struct TagsInputEvent(pub Vec<String>);
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum Commit {
38 Added,
40 Duplicate,
42 Rejected,
44}
45
46fn 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
62pub 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 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 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 pub fn tag_values(&self) -> &[String] {
142 &self.tags
143 }
144
145 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 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 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 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 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 assert_eq!(commit_tag(&mut tags, "a", Some(2)), Commit::Duplicate);
368 }
369}