liora-components 0.1.5

Enterprise-style native GPUI component library for Liora applications.
Documentation
use crate::Input;
use crate::gpui_compat::element_id;
use gpui::{App, Context, Entity, Render, SharedString, Window, div, prelude::*, px};
use liora_core::Config;
use liora_icons::Icon;
use liora_icons_lucide::IconName;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MentionItem {
    pub value: SharedString,
    pub label: SharedString,
    pub description: Option<SharedString>,
}

impl MentionItem {
    pub fn new(value: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
        Self {
            value: value.into(),
            label: label.into(),
            description: None,
        }
    }
    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
        self.description = Some(description.into());
        self
    }
}

pub struct Mention {
    input: Entity<Input>,
    trigger: char,
    suggestions: Vec<MentionItem>,
    max_suggestions: usize,
    placeholder: SharedString,
    disabled: bool,
    selected_index: usize,
    on_select: Option<std::sync::Arc<dyn Fn(MentionItem, &mut Window, &mut App) + 'static>>,
}

impl Mention {
    pub fn new(suggestions: Vec<MentionItem>, cx: &mut Context<Self>) -> Self {
        Self {
            input: cx.new(|cx| Input::new("", cx)),
            trigger: '@',
            suggestions,
            max_suggestions: 6,
            placeholder: "Type @ to mention".into(),
            disabled: false,
            selected_index: 0,
            on_select: None,
        }
    }
    pub fn entity(suggestions: Vec<MentionItem>, cx: &mut App) -> Entity<Self> {
        cx.new(|cx| Self::new(suggestions, cx))
    }
    pub fn trigger(mut self, trigger: char) -> Self {
        self.trigger = trigger;
        self
    }
    pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
        self.placeholder = placeholder.into();
        self
    }
    pub fn max_suggestions(mut self, max: usize) -> Self {
        self.max_suggestions = max.max(1);
        self
    }
    pub fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }
    pub fn on_select(mut self, cb: impl Fn(MentionItem, &mut Window, &mut App) + 'static) -> Self {
        self.on_select = Some(std::sync::Arc::new(cb));
        self
    }
    pub fn input(&self) -> Entity<Input> {
        self.input.clone()
    }
    pub fn query_for_text(text: &str, trigger: char) -> Option<&str> {
        mention_query(text, trigger)
    }
    pub fn filtered_suggestions(&self, query: &str) -> Vec<MentionItem> {
        filter_suggestions(&self.suggestions, query, self.max_suggestions)
    }
}

impl Render for Mention {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = cx.global::<Config>().theme.clone();
        let input = self.input.clone();
        let placeholder = self.placeholder.clone();
        let disabled = self.disabled;
        cx.update_entity(&input, |input, cx| {
            input.set_placeholder(placeholder, cx);
            input.set_disabled(disabled, cx);
        });
        let text = input.read(cx).value().to_string();
        let query = mention_query(&text, self.trigger).unwrap_or("");
        let open = !disabled
            && text.contains(self.trigger)
            && mention_query(&text, self.trigger).is_some();
        let suggestions = if open {
            self.filtered_suggestions(query)
        } else {
            Vec::new()
        };
        let on_select = self.on_select.clone();

        div()
            .id(liora_core::unique_id("mention"))
            .relative()
            .flex()
            .flex_col()
            .gap_2()
            .child(input)
            .when(open, |s| {
                s.child(
                    div()
                        .rounded_md()
                        .border_1()
                        .border_color(theme.neutral.border)
                        .bg(theme.neutral.card)
                        .shadow_lg()
                        .overflow_hidden()
                        .children(suggestions.into_iter().enumerate().map(|(idx, item)| {
                            let active = idx
                                == self
                                    .selected_index
                                    .min(self.max_suggestions.saturating_sub(1));
                            let selected_item = item.clone();
                            let mut row = div()
                                .id(element_id(format!("mention-option-{}", idx)))
                                .flex()
                                .items_center()
                                .gap_2()
                                .px_3()
                                .py_2()
                                .cursor_pointer()
                                .bg(if active {
                                    theme.primary.base.opacity(0.08)
                                } else {
                                    gpui::transparent_black()
                                })
                                .hover(|s| s.bg(theme.neutral.hover))
                                .child(
                                    Icon::new(IconName::AtSign)
                                        .size(px(15.0))
                                        .color(theme.primary.base),
                                )
                                .child(
                                    div()
                                        .flex()
                                        .flex_col()
                                        .child(
                                            div()
                                                .text_sm()
                                                .text_color(theme.neutral.text_1)
                                                .child(item.label),
                                        )
                                        .when_some(item.description, |s, description| {
                                            s.child(
                                                div()
                                                    .text_xs()
                                                    .text_color(theme.neutral.text_3)
                                                    .child(description),
                                            )
                                        }),
                                );
                            if let Some(cb) = on_select.clone() {
                                row = row.on_click(move |_, window, cx| {
                                    cb(selected_item.clone(), window, cx)
                                });
                            }
                            row.into_any_element()
                        })),
                )
            })
    }
}

fn mention_query(text: &str, trigger: char) -> Option<&str> {
    let last = text.rfind(trigger)?;
    let query = &text[last + trigger.len_utf8()..];
    if query.chars().any(char::is_whitespace) {
        None
    } else {
        Some(query)
    }
}

fn filter_suggestions(items: &[MentionItem], query: &str, limit: usize) -> Vec<MentionItem> {
    let query = query.to_lowercase();
    items
        .iter()
        .filter(|item| {
            query.is_empty()
                || item.value.to_lowercase().contains(&query)
                || item.label.to_lowercase().contains(&query)
        })
        .take(limit.max(1))
        .cloned()
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn mention_query_reads_last_trigger_until_space() {
        assert_eq!(Mention::query_for_text("hello @al", '@'), Some("al"));
        assert_eq!(Mention::query_for_text("hello @al ice", '@'), None);
    }
    #[test]
    fn mention_filter_matches_value_or_label_and_caps() {
        let items = vec![
            MentionItem::new("alice", "Alice"),
            MentionItem::new("bob", "Bob"),
            MentionItem::new("ops", "Operations"),
        ];
        let out = filter_suggestions(&items, "o", 1);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].value.as_ref(), "bob");
    }
}