fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Tag` — the web tag/chip. It paints the variant × priority colors the
//! domain resolves, with the web extras: a leading icon, a bold title
//! prefix and bold filter-values suffix, plain-label truncation, a close
//! button, and a trailing link. The close button composes an [`Icon`]
//! directly while the icon-button component remains unported.

use fluidattacks_designs_domain::icon::{Size as IconSize, Style as IconStyle};
use fluidattacks_designs_domain::tag::{
    colors, padding, radius, Priority, Variant, CLOSE_ICON, CLOSE_PADDING, LINK_GAP, TRUNCATE_AT,
};
use fluidattacks_designs_domain::theme;
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, rgb, App, ClickEvent, CursorStyle, ElementId, FontWeight, InteractiveElement as _,
    IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
    Styled as _, Window,
};

use crate::icon::Icon;
use crate::link::Link;
use crate::theme::ActiveTheme as _;

type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;

#[derive(IntoElement)]
pub struct Tag {
    label: SharedString,
    variant: Variant,
    priority: Priority,
    icon: Option<(SharedString, IconStyle, Option<u32>)>,
    title: SharedString,
    filter_values: SharedString,
    truncate: bool,
    font_size: Option<f32>,
    disabled: bool,
    on_click: Option<(ElementId, ClickHandler)>,
    on_close: Option<(ElementId, ClickHandler)>,
    link: Option<(ElementId, SharedString)>,
    link_label: SharedString,
}

impl Tag {
    pub fn new(label: impl Into<SharedString>) -> Self {
        Self {
            label: label.into(),
            variant: Variant::default(),
            priority: Priority::default(),
            icon: None,
            title: SharedString::default(),
            filter_values: SharedString::default(),
            truncate: true,
            font_size: None,
            disabled: false,
            on_click: None,
            on_close: None,
            link: None,
            link_label: SharedString::default(),
        }
    }

    #[must_use]
    pub const fn variant(mut self, variant: Variant) -> Self {
        self.variant = variant;
        self
    }

    #[must_use]
    pub const fn priority(mut self, priority: Priority) -> Self {
        self.priority = priority;
        self
    }

    /// A leading icon (web defaults: fa-light, inherited color).
    #[must_use]
    pub fn icon(mut self, name: impl Into<SharedString>) -> Self {
        self.icon = Some((name.into(), IconStyle::Light, None));
        self
    }

    #[must_use]
    pub fn icon_styled(
        mut self,
        name: impl Into<SharedString>,
        style: IconStyle,
        color: Option<u32>,
    ) -> Self {
        self.icon = Some((name.into(), style, color));
        self
    }

    /// The bold prefix before the label (web `tagTitle`).
    #[must_use]
    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
        self.title = title.into();
        self
    }

    /// The bold suffix after the label (web `filterValues`).
    #[must_use]
    pub fn filter_values(mut self, filter_values: impl Into<SharedString>) -> Self {
        self.filter_values = filter_values.into();
        self
    }

    /// Whether plain labels truncate past 25 chars (on by default).
    #[must_use]
    pub const fn truncate(mut self, truncate: bool) -> Self {
        self.truncate = truncate;
        self
    }

    /// Override the default text-sm size (web `fontSize`).
    #[must_use]
    pub const fn font_size(mut self, font_size: f32) -> Self {
        self.font_size = Some(font_size);
        self
    }

    /// Disables the close button only, like the web.
    #[must_use]
    pub const fn disabled(mut self, disabled: bool) -> Self {
        self.disabled = disabled;
        self
    }

    /// Click needs an id: gpui tracks interactive elements by it.
    #[must_use]
    pub fn on_click(
        mut self,
        id: impl Into<ElementId>,
        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_click = Some((id.into(), Box::new(handler)));
        self
    }

    /// The trailing close button (web `onClose`).
    #[must_use]
    pub fn on_close(
        mut self,
        id: impl Into<ElementId>,
        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_close = Some((id.into(), Box::new(handler)));
        self
    }

    /// The trailing link after the tag body (web `href`); without a label
    /// only the external glyph shows, like the web. The id disambiguates
    /// sibling linked tags, like `on_click`/`on_close`.
    #[must_use]
    pub fn link(mut self, id: impl Into<ElementId>, href: impl Into<SharedString>) -> Self {
        self.link = Some((id.into(), href.into()));
        self
    }

    /// The trailing link's text (web `linkLabel`).
    #[must_use]
    pub fn link_label(mut self, label: impl Into<SharedString>) -> Self {
        self.link_label = label.into();
        self
    }

    /// The chip itself: colors, metrics, icon, texts and the close button.
    fn chip(self, theme: &theme::Colors) -> gpui::Div {
        let palette = colors(self.variant, self.priority, theme);
        let (pad_y, pad_x) = padding(self.variant);
        let label = self.shown_label();
        div()
            .flex()
            .items_center()
            .justify_center()
            .gap(px(4.0))
            .rounded(px(radius(self.variant)))
            .py(px(pad_y))
            .px(px(pad_x))
            .font_family(FONT_PRIMARY)
            .text_size(px(self.font_size.unwrap_or(TextSize::Sm.px())))
            .line_height(px(18.0))
            .whitespace_nowrap()
            .text_color(rgb(palette.fg))
            .when_some(palette.bg, |el, bg| el.bg(rgb(bg)))
            .when_some(palette.border, |el, border| {
                el.border_1().border_color(rgb(border))
            })
            .when_some(self.icon, |el, (name, style, color)| {
                // svg glyphs don't inherit the cascade color in gpui; the
                // web's "inherit" default resolves to the tag's own text
                el.child(
                    Icon::new(name)
                        .style(style)
                        .size(IconSize::Sm)
                        .color(color.unwrap_or(palette.fg)),
                )
            })
            .when(!self.title.is_empty(), |el| {
                el.child(div().font_weight(FontWeight::BOLD).child(self.title))
            })
            .child(label)
            .when(!self.filter_values.is_empty(), |el| {
                el.child(
                    div()
                        .font_weight(FontWeight::BOLD)
                        .child(self.filter_values),
                )
            })
            .when_some(self.on_close, |el, (id, handler)| {
                el.child(close_button(id, handler, self.disabled, theme))
            })
    }

    /// The web splits only plain labels: title or values suppress it.
    fn shown_label(&self) -> SharedString {
        let plain = self.title.is_empty() && self.filter_values.is_empty();
        if !(self.truncate && plain) || self.label.chars().count() <= TRUNCATE_AT {
            return self.label.clone();
        }
        let cut: String = self.label.chars().take(TRUNCATE_AT).collect();
        format!("{cut}...").into()
    }
}

/// The xmark trailing button: 2px-padded ghost, inert and dimmed when
/// disabled, swallowing the click when the body is clickable too.
fn close_button(
    id: ElementId,
    handler: ClickHandler,
    disabled: bool,
    theme: &theme::Colors,
) -> impl IntoElement {
    let glyph_color = if disabled {
        theme.disabled.on
    } else {
        // the web close is a ghost icon-button: gray-800 whatever the tag
        theme.text_strong
    };
    let close = div()
        .id(id)
        .flex()
        .items_center()
        .p(px(CLOSE_PADDING))
        .rounded(px(4.0))
        .child(
            Icon::new(CLOSE_ICON)
                .style(IconStyle::Light)
                .size(IconSize::Sm)
                .color(glyph_color),
        );
    if disabled {
        close.cursor(CursorStyle::OperationNotAllowed)
    } else {
        let hover_bg = rgb(theme.surface_muted);
        close
            .cursor_pointer()
            .hover(move |style| style.bg(hover_bg))
            .on_click(move |event, window, cx| {
                cx.stop_propagation();
                handler(event, window, cx);
            })
    }
}

impl RenderOnce for Tag {
    fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().colors;
        let on_click = self.on_click.take();
        let link = self.link.take();
        let link_label = core::mem::take(&mut self.link_label);
        let chip = self.chip(&theme);
        let body = match on_click {
            Some((id, handler)) => chip
                .id(id)
                .cursor_pointer()
                .on_click(handler)
                .into_any_element(),
            None => chip.into_any_element(),
        };
        match link {
            Some((id, href)) => div()
                .flex()
                .items_center()
                .gap(px(LINK_GAP))
                .child(body)
                .child(Link::new(id, link_label).href(href))
                .into_any_element(),
            None => body,
        }
    }
}