fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Link` — the web anchor: underlined text-md swapping color on hover,
//! with the external glyph on https destinations. Clicking opens the
//! destination in the default browser; in-app routes are the consuming
//! app's concern (pass a plain `on_click` and no href).

use fluidattacks_designs_domain::icon::{Size as IconSize, Style as IconStyle};
use fluidattacks_designs_domain::link::{
    is_external, Variant, DOWNLOAD_ICON, EXTERNAL_ICON, GAP, ICON_MARGIN_X, LINE_HEIGHT,
};
use fluidattacks_designs_domain::typography::{TextSize, FONT_PRIMARY};
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, rgb, App, ClickEvent, CursorStyle, ElementId, InteractiveElement as _, IntoElement,
    ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement as _, Styled as _,
    Window,
};

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

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

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum IconPosition {
    Left,
    #[default]
    Right,
}

#[derive(IntoElement)]
pub struct Link {
    id: ElementId,
    label: SharedString,
    href: Option<SharedString>,
    variant: Variant,
    color: Option<u32>,
    download: bool,
    icon_position: IconPosition,
    on_click: Option<ClickHandler>,
}

impl Link {
    #[must_use]
    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
        Self {
            id: id.into(),
            label: label.into(),
            href: None,
            variant: Variant::default(),
            color: None,
            download: false,
            icon_position: IconPosition::default(),
            on_click: None,
        }
    }

    /// The destination, opened in the default browser on click.
    #[must_use]
    pub fn href(mut self, href: impl Into<SharedString>) -> Self {
        self.href = Some(href.into());
        self
    }

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

    /// Override the variant color (the web `color` prop).
    #[must_use]
    pub const fn color(mut self, color: u32) -> Self {
        self.color = Some(color);
        self
    }

    /// Swap the external glyph for the download one.
    #[must_use]
    pub const fn download(mut self, download: bool) -> Self {
        self.download = download;
        self
    }

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

    #[must_use]
    pub fn on_click(
        mut self,
        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
    ) -> Self {
        self.on_click = Some(Box::new(on_click));
        self
    }
}

/// svg glyphs don't inherit the cascade color in gpui.
fn external_glyph(download: bool, color: u32) -> gpui::Div {
    div().mx(px(ICON_MARGIN_X)).child(
        Icon::new(if download {
            DOWNLOAD_ICON
        } else {
            EXTERNAL_ICON
        })
        .style(IconStyle::Light)
        .size(IconSize::Sm)
        .color(color),
    )
}

impl RenderOnce for Link {
    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
        let hovered = window.use_keyed_state(self.id.clone(), cx, |_, _| false);
        let is_hovered = *hovered.read(cx);
        // the web link swaps color instantly (no transition rule); an
        // explicit color is the caller's own, already in the active mode
        let color = self.color.unwrap_or_else(|| {
            let variant = if is_hovered {
                self.variant.hover()
            } else {
                self.variant.color()
            };
            cx.theme().shade(variant)
        });
        let glyph = (self.href.as_ref().is_some_and(|href| is_external(href)))
            .then(|| external_glyph(self.download, color));
        let href = self.href;
        let on_click = self.on_click;
        let (left_glyph, right_glyph) = match self.icon_position {
            IconPosition::Left => (glyph, None),
            IconPosition::Right => (None, glyph),
        };
        div()
            .id(self.id)
            .flex()
            .items_center()
            .gap(px(GAP))
            .cursor(CursorStyle::PointingHand)
            .font_family(FONT_PRIMARY)
            .text_size(px(TextSize::Md.px()))
            .line_height(px(LINE_HEIGHT))
            .underline()
            .text_color(rgb(color))
            .on_hover(move |now_hovered, hover_window, hover_cx| {
                hovered.update(hover_cx, |state, _| *state = *now_hovered);
                hover_window.refresh();
            })
            .on_click(move |event, click_window, click_cx| {
                if let Some(href) = href.as_ref() {
                    click_cx.open_url(href);
                }
                if let Some(handler) = on_click.as_ref() {
                    handler(event, click_window, click_cx);
                }
            })
            .when_some(left_glyph, gpui::ParentElement::child)
            .child(self.label)
            .when_some(right_glyph, gpui::ParentElement::child)
    }
}