fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `EmptyState` — the web centered column: an optional illustration slot,
//! title, description, and optional confirm/cancel buttons.
//!
//! The slot defaults to the web's illustration (via [`crate::CloudImage`]);
//! [`EmptyState::image_id`] picks another from the design system's list and
//! [`EmptyState::image`] accepts any custom element.

use crate::button::Button;
use crate::cloud_image::CloudImage;
use crate::text::{Heading, Text};
use fluidattacks_designs_domain::button::Variant;
use fluidattacks_designs_domain::empty_state::{
    Size, BUTTON_GAP, BUTTON_ROW_MARGIN_BOTTOM, CANCEL_VARIANT, CONFIRM_VARIANT, DEFAULT_IMAGE,
    IMAGE_MARGIN_BOTTOM, IMAGE_SIZE, MAX_WIDTH, PADDING, TEXT_MARGIN_BOTTOM, TITLE_MARGIN_BOTTOM,
};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, AnyElement, App, ClickEvent, ElementId, IntoElement, ParentElement as _, RenderOnce,
    SharedString, Styled as _, Window,
};

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

struct Action {
    id: ElementId,
    label: SharedString,
    handler: ClickHandler,
}

#[derive(IntoElement)]
pub struct EmptyState {
    title: SharedString,
    description: SharedString,
    size: Size,
    padding: f32,
    image: Option<AnyElement>,
    confirm: Option<Action>,
    confirm_variant: Variant,
    cancel: Option<Action>,
    cancel_variant: Variant,
}

impl EmptyState {
    pub fn new(title: impl Into<SharedString>, description: impl Into<SharedString>) -> Self {
        Self {
            title: title.into(),
            description: description.into(),
            size: Size::default(),
            padding: PADDING,
            image: Some(
                CloudImage::new(DEFAULT_IMAGE)
                    .width(IMAGE_SIZE)
                    .into_any_element(),
            ),
            confirm: None,
            confirm_variant: CONFIRM_VARIANT,
            cancel: None,
            cancel_variant: CANCEL_VARIANT,
        }
    }

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

    /// The container padding, like the web `padding` prop (default 20px).
    #[must_use]
    pub const fn padding(mut self, padding: f32) -> Self {
        self.padding = padding;
        self
    }

    #[must_use]
    pub fn image(mut self, image: impl IntoElement) -> Self {
        self.image = Some(image.into_any_element());
        self
    }

    /// Swap the illustration for another design-system public id.
    #[must_use]
    pub fn image_id(mut self, public_id: impl Into<gpui::SharedString>) -> Self {
        self.image = Some(
            CloudImage::new(public_id)
                .width(IMAGE_SIZE)
                .into_any_element(),
        );
        self
    }

    #[must_use]
    pub fn confirm(
        mut self,
        id: impl Into<ElementId>,
        label: impl Into<SharedString>,
        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.confirm = Some(Action {
            id: id.into(),
            label: label.into(),
            handler: Box::new(handler),
        });
        self
    }

    /// Override the confirm button's default primary variant; applies
    /// regardless of builder order.
    #[must_use]
    pub const fn confirm_variant(mut self, variant: Variant) -> Self {
        self.confirm_variant = variant;
        self
    }

    #[must_use]
    pub fn cancel(
        mut self,
        id: impl Into<ElementId>,
        label: impl Into<SharedString>,
        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.cancel = Some(Action {
            id: id.into(),
            label: label.into(),
            handler: Box::new(handler),
        });
        self
    }

    /// Override the cancel button's default tertiary variant; applies
    /// regardless of builder order.
    #[must_use]
    pub const fn cancel_variant(mut self, variant: Variant) -> Self {
        self.cancel_variant = variant;
        self
    }
}

fn button_row(actions: [(Option<Action>, Variant); 2]) -> gpui::Div {
    let mut row = div()
        .flex()
        .gap(px(BUTTON_GAP))
        .mb(px(BUTTON_ROW_MARGIN_BOTTOM));
    for (action, variant) in actions {
        let Some(action) = action else { continue };
        row = row.child(
            Button::new(action.id, action.label)
                .variant(variant)
                .on_click(action.handler),
        );
    }
    row
}

impl RenderOnce for EmptyState {
    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
        let has_buttons = self.confirm.is_some() || self.cancel.is_some();
        let buttons = has_buttons.then(|| {
            button_row([
                (self.cancel, self.cancel_variant),
                (self.confirm, self.confirm_variant),
            ])
        });
        div()
            .flex()
            .flex_col()
            .items_center()
            .p(px(self.padding))
            .max_w(px(MAX_WIDTH))
            .mx_auto()
            .when_some(self.image, |el, image| {
                el.child(
                    div()
                        .flex()
                        .items_center()
                        .justify_center()
                        .size(px(IMAGE_SIZE))
                        .mb(px(IMAGE_MARGIN_BOTTOM))
                        .child(image),
                )
            })
            .child(
                div()
                    .mb(px(TITLE_MARGIN_BOTTOM))
                    .text_center()
                    .child(Heading::new(self.title).size(self.size.title())),
            )
            .child(
                div()
                    .when(has_buttons, |el| el.mb(px(TEXT_MARGIN_BOTTOM)))
                    .text_center()
                    .child(Text::new(self.description).size(self.size.description())),
            )
            .when_some(buttons, gpui::ParentElement::child)
    }
}