use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
use crate::controls::field::{FieldState, field_shell};
use crate::controls::input::{TextInput, TextInputEvent};
use crate::display::tag::Tag;
use crate::foundation::{
Disableable, Ident, Selectable, Sizable, StyledExt, text as foundation_text,
};
use crate::strings::{ActiveStrings, StringKey};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagInputEvent {
Added(SharedString),
Removed(SharedString),
Duplicate(SharedString),
Refused(SharedString),
}
impl EventEmitter<TagInputEvent> for TagInput {}
pub struct TagInput {
ident: Ident,
focus_handle: FocusHandle,
field: Entity<TextInput>,
tags: Vec<SharedString>,
placeholder: Option<SharedString>,
max: Option<usize>,
size: ControlSize,
disabled: bool,
invalid: bool,
targeted: Option<SharedString>,
refusal: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl std::fmt::Debug for TagInput {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TagInput")
.field("ident", &self.ident)
.field("tags", &self.tags.len())
.field("max", &self.max)
.field("targeted", &self.targeted)
.field("disabled", &self.disabled)
.finish()
}
}
impl TagInput {
pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let ident = ident.into();
let field = cx.new(|cx| TextInput::new(ident.child("field"), window, cx).bare(true));
let subscription = cx.subscribe(&field, |tags, _field, event, cx| match event {
TextInputEvent::Change(text) => tags.on_change(text.clone(), cx),
TextInputEvent::Submit => tags.commit(cx),
TextInputEvent::BackspaceAtStart => tags.backspace(cx),
TextInputEvent::Cancel => tags.untarget(cx),
_ => {}
});
Self {
ident,
focus_handle: cx.focus_handle(),
field,
tags: Vec::new(),
placeholder: None,
max: None,
size: ControlSize::Md,
disabled: false,
invalid: false,
targeted: None,
refusal: None,
_subscriptions: vec![subscription],
}
}
pub fn tags(mut self, tags: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.tags = tags.into_iter().map(Into::into).collect();
self
}
pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = Some(placeholder.into());
self
}
pub fn max(mut self, max: usize) -> Self {
self.max = Some(max);
self
}
pub fn invalid(mut self, invalid: bool) -> Self {
self.invalid = invalid;
self
}
pub fn set_tags(&mut self, tags: Vec<SharedString>, cx: &mut Context<Self>) {
self.tags = tags;
self.targeted = None;
self.refusal = None;
cx.notify();
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled;
self.field
.update(cx, |field, cx| field.set_disabled(disabled, cx));
cx.notify();
}
pub fn current(&self) -> &[SharedString] {
&self.tags
}
pub fn field(&self) -> &Entity<TextInput> {
&self.field
}
pub fn targeted(&self) -> Option<&SharedString> {
self.targeted.as_ref()
}
pub fn refusal(&self) -> Option<&SharedString> {
self.refusal.as_ref()
}
fn is_full(&self) -> bool {
self.max.is_some_and(|max| self.tags.len() >= max)
}
fn clear_field(&mut self, cx: &mut Context<Self>) {
self.field
.update(cx, |field, cx| field.set_text_quietly("", cx));
}
fn on_change(&mut self, text: SharedString, cx: &mut Context<Self>) {
if !text.is_empty() {
self.targeted = None;
}
if text.ends_with(',') {
let value = SharedString::from(text.trim_end_matches(',').to_string());
self.add(value, cx);
}
cx.notify();
}
fn commit(&mut self, cx: &mut Context<Self>) {
let typed = self.field.read(cx).value().clone();
self.add(typed, cx);
}
fn add(&mut self, value: SharedString, cx: &mut Context<Self>) {
let trimmed = value.trim();
if trimmed.is_empty() {
self.clear_field(cx);
return;
}
let value = SharedString::from(trimmed.to_string());
if self.tags.iter().any(|tag| tag == &value) {
self.refusal = Some(cx.strings().format(StringKey::TagInputDuplicate, &[&value]));
cx.emit(TagInputEvent::Duplicate(value));
cx.notify();
return;
}
if self.is_full() {
let max = self.max.unwrap_or_default();
self.refusal = Some(
cx.strings()
.format(StringKey::TagInputFull, &[&max.to_string(), &value]),
);
cx.emit(TagInputEvent::Refused(value));
cx.notify();
return;
}
self.refusal = None;
self.clear_field(cx);
cx.emit(TagInputEvent::Added(value));
cx.notify();
}
fn remove(&mut self, value: SharedString, cx: &mut Context<Self>) {
self.targeted = None;
self.refusal = None;
cx.emit(TagInputEvent::Removed(value));
cx.notify();
}
fn backspace(&mut self, cx: &mut Context<Self>) {
if self.disabled || !self.field.read(cx).value().is_empty() {
return;
}
match self.targeted.clone() {
Some(value) if self.tags.iter().any(|tag| tag == &value) => self.remove(value, cx),
_ => {
self.targeted = self.tags.last().cloned();
cx.notify();
}
}
}
fn untarget(&mut self, cx: &mut Context<Self>) {
if self.targeted.take().is_some() {
cx.notify();
}
}
}
impl Disableable for TagInput {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl Sizable for TagInput {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl Focusable for TagInput {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for TagInput {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
if self.field.read(cx).placeholder_text().is_empty() {
let placeholder = self
.placeholder
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::TagInputPlaceholder));
self.field
.update(cx, |field, cx| field.set_placeholder(placeholder, cx));
}
if self.disabled != self.field.read(cx).is_disabled() {
let disabled = self.disabled;
self.field
.update(cx, |field, cx| field.set_disabled(disabled, cx));
}
let focused = self.field.read(cx).focus_handle(cx).is_focused(window);
let invalid = self.invalid || self.refusal.is_some();
let full = self.is_full();
let control = cx.entity().downgrade();
let tags = self
.tags
.iter()
.map(|tag_value| {
let ident = self.ident.child(tag_value.as_ref());
let removing = tag_value.clone();
let control = control.clone();
Tag::new(ident, tag_value.clone())
.selected(self.targeted.as_ref() == Some(tag_value))
.disabled(self.disabled)
.on_remove(move |_window, cx| {
control
.update(cx, |tags, cx| tags.remove(removing.clone(), cx))
.ok();
})
})
.collect::<Vec<_>>();
let count = self.tags.len();
let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Group)
.disabled(self.disabled)
.invalid(invalid)
.focus(&self.field.read(cx).focus_handle(cx))
.value(SharedString::from(match self.max {
Some(max) => format!("{count} of {max}"),
None => count.to_string(),
}));
if let Some(refusal) = self.refusal.clone() {
spec = spec.text(refusal);
}
div()
.id(self.ident.element_id())
.column()
.w_full()
.gap(px(theme.space(Space::Xs)))
.track_focus(&self.focus_handle)
.child(
field_shell(
&theme,
self.size,
FieldState::default()
.focused(focused)
.invalid(invalid)
.disabled(self.disabled),
)
.flex_wrap()
.py(px(theme.space(Space::Xs)))
.gap(px(theme.space(Space::Xs)))
.children(tags)
.child(div().flex_1().min_w(px(80.0)).child(self.field.clone())),
)
.children(self.refusal.clone().map(|refusal| {
foundation_text(&theme, TypeScale::Caption, refusal.clone())
.text_color(theme.colors.danger)
.semantic_in(
cx,
NodeSpec::new(self.ident.child("refusal").semantic_id(), Role::Status)
.parent(self.ident.semantic_id())
.invalid(true)
.text(refusal),
)
}))
.when(full && self.refusal.is_none(), |element| {
element.child(
foundation_text(
&theme,
TypeScale::Caption,
cx.strings().format(
StringKey::TagInputUsed,
&[
&count.to_string(),
&self.max.unwrap_or_default().to_string(),
],
),
)
.text_tone(&theme, gpui_kit_theme::TextTone::Muted),
)
})
.semantic_in(cx, spec)
}
}