fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Avatar` — the web initials circle with its optional username label.
//!
//! Clickable mode mirrors the web (a ghost button wraps the circle): ghost
//! hover fill, pointer cursor, 8px padding. Image faces are not part of the
//! design system; apps that show one compose this circle as their fallback.

use crate::icon::Icon;
use crate::theme::ActiveTheme as _;
use fluidattacks_designs_domain::icon::Size as IconSize;
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use fluidattacks_designs_domain::{avatar, button, theme};
use gpui::{
    div, px, rgb, App, ClickEvent, Div, ElementId, FontWeight, InteractiveElement as _,
    IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _,
    Styled as _, Window,
};

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

#[derive(IntoElement)]
pub struct Avatar {
    user_name: SharedString,
    show_username: bool,
    show_icon: bool,
    on_click: Option<(ElementId, ClickHandler)>,
}

impl Avatar {
    pub fn new(user_name: impl Into<SharedString>) -> Self {
        Self {
            user_name: user_name.into(),
            show_username: false,
            show_icon: false,
            on_click: None,
        }
    }

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

    /// The trailing ellipsis glyph, the web's dropdown affordance.
    #[must_use]
    pub const fn show_icon(mut self, show: bool) -> Self {
        self.show_icon = show;
        self
    }

    #[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
    }
}

fn circle(user_name: &str, theme: &theme::Colors) -> Div {
    let letter = avatar::initial(user_name).unwrap_or('?');
    div()
        .flex()
        .items_center()
        .justify_center()
        .size(px(avatar::SIZE))
        .rounded_full()
        .bg(rgb(theme.shade(avatar::BG)))
        .text_color(rgb(theme.shade(avatar::FG)))
        .font_family(FONT_PRIMARY)
        .text_size(px(avatar::FONT_SIZE))
        .line_height(px(avatar::LINE_HEIGHT))
        .font_weight(FontWeight(f32::from(avatar::FONT_WEIGHT)))
        .child(SharedString::from(letter.to_string()))
}

fn username_label(
    avatar_el: Div,
    user_name: &str,
    interactive: bool,
    theme: &theme::Colors,
) -> Div {
    let Some(first) = avatar::first_name(user_name) else {
        return avatar_el;
    };
    let label = if interactive {
        first.to_owned()
    } else {
        format!("Hi {first}!")
    };
    avatar_el.child(
        div()
            .font_family(FONT_PRIMARY)
            .text_size(px(TextSize::Md.px()))
            .line_height(px(TextSize::Md.line_height_px()))
            .text_color(rgb(theme.text_strong))
            .child(SharedString::from(label)),
    )
}

impl RenderOnce for Avatar {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let theme = cx.theme().colors;
        let interactive = self.on_click.is_some();
        let mut base = div()
            .flex()
            .items_center()
            .gap(px(8.0))
            .p(px(8.0))
            .child(circle(&self.user_name, &theme));
        if self.show_username {
            base = username_label(base, &self.user_name, interactive, &theme);
        }
        if self.show_icon {
            // svg glyphs don't inherit the cascade color in gpui; the web's
            // default resolves to the label's gray-800
            base = base.child(
                Icon::new("ellipsis-vertical")
                    .size(IconSize::Xs)
                    .color(theme.text_strong),
            );
        }
        match self.on_click {
            Some((id, handler)) => base
                .id(id)
                .rounded(px(button::spacing(button::Variant::Ghost)))
                .cursor_pointer()
                .hover(move |style| style.bg(rgb(theme.surface_muted)))
                .on_click(handler)
                .into_any_element(),
            None => base.into_any_element(),
        }
    }
}