1use 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#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum TagInputEvent {
28 Added(SharedString),
30 Removed(SharedString),
32 Duplicate(SharedString),
35 Refused(SharedString),
37}
38
39impl EventEmitter<TagInputEvent> for TagInput {}
40
41pub 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 targeted: Option<SharedString>,
58 refusal: Option<SharedString>,
60 _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 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 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 pub fn targeted(&self) -> Option<&SharedString> {
153 self.targeted.as_ref()
154 }
155
156 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 fn on_change(&mut self, text: SharedString, cx: &mut Context<Self>) {
175 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 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 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}