1use 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#[derive(Debug, Clone)]
35pub struct TagsInputEvent(pub Vec<String>);
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Commit {
40 Added,
42 Duplicate,
44 Rejected,
46}
47
48fn 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
64pub 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 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 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 pub fn tag_values(&self) -> &[String] {
146 &self.tags
147 }
148
149 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 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 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 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 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 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 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 assert_eq!(commit_tag(&mut tags, "a", Some(2)), Commit::Duplicate);
399 }
400}