fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Checkbox` — the web on/off control: a 16px square that fills with the
//! brand color when ticked, an optional label that toggles along with it, and
//! an optional tip riding an info glyph beside them.
//!
//! Controlled, like the web: it paints the `checked` it is handed and reports
//! the value a click would produce.
//!
//! ```ignore
//! Checkbox::new("only-tests", self.only_tests)
//!     .label("Only in tests")
//!     .on_change(cx.listener(|this, checked, _, cx| {
//!         this.only_tests = *checked;
//!         cx.notify();
//!     }))
//! ```
//!
//! Pointer-driven and takes no focus: the web leaves the keyboard to its
//! native input, and there is none to stand on here.

use fluidattacks_designs_domain::checkbox::{
    ring_color, tones, BOX_MARGIN_RIGHT, BOX_RADIUS, BOX_SIZE, GLYPH, GLYPH_COLOR, GLYPH_SIZE,
    GLYPH_STYLE, LABEL_COLOR, LABEL_DISABLED_COLOR, LABEL_LINE_HEIGHT, LABEL_SIZE, LABEL_WEIGHT,
    RING, TOOLTIP_GAP, TOOLTIP_GLYPH, TOOLTIP_GLYPH_SIZE, TOOLTIP_GLYPH_STYLE, TOOLTIP_PLACE,
};
use fluidattacks_designs_domain::typography::FONT_PRIMARY;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, point, px, rgb, rgb_to_hsla, App, BoxShadow, Div, ElementId, FontWeight,
    InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
    StatefulInteractiveElement as _, Styled as _, Window,
};

use crate::theme::{ActiveTheme as _, Theme};
use crate::{Icon, Tooltip};

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

#[derive(IntoElement)]
pub struct Checkbox {
    id: ElementId,
    checked: bool,
    label: Option<SharedString>,
    disabled: bool,
    tooltip: Option<(ElementId, SharedString)>,
    on_change: Option<ChangeHandler>,
}

impl Checkbox {
    pub fn new(id: impl Into<ElementId>, checked: bool) -> Self {
        Self {
            id: id.into(),
            checked,
            label: None,
            disabled: false,
            tooltip: None,
            on_change: None,
        }
    }

    /// Text beside the square, clickable as part of the control.
    #[must_use]
    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
        self.label = Some(label.into());
        self
    }

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

    /// A tip on an info glyph after the label, outside the clickable control.
    /// It carries its own id: the glyph is a sibling of the control, not its
    /// wrapper, so sharing one would land both on the same element state.
    #[must_use]
    pub fn tooltip(mut self, id: impl Into<ElementId>, tip: impl Into<SharedString>) -> Self {
        self.tooltip = Some((id.into(), tip.into()));
        self
    }

    /// Called with the state the click produces, never with the current one.
    #[must_use]
    pub fn on_change(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
        self.on_change = Some(Box::new(handler));
        self
    }
}

fn ring(checked: bool, theme: &Theme) -> BoxShadow {
    BoxShadow {
        color: rgb_to_hsla(rgb(theme.shade(ring_color(checked)))),
        offset: point(px(0.0), px(0.0)),
        blur_radius: px(0.0),
        spread_radius: px(RING),
        inset: false,
    }
}

fn square(checked: bool, disabled: bool, theme: &Theme) -> Div {
    let painted = tones(checked, disabled);
    let ring = ring(checked, theme);
    div()
        .flex()
        .flex_none()
        .items_center()
        .justify_center()
        .size(px(BOX_SIZE))
        // the web hangs the spacing off the square, so it holds with no label
        .mr(px(BOX_MARGIN_RIGHT))
        .rounded(px(BOX_RADIUS))
        .border_1()
        .border_color(rgb(theme.shade(painted.border)))
        .bg(rgb(theme.shade(painted.bg)))
        .when(!disabled, |el| {
            el.hover(move |style| style.shadow(vec![ring]))
        })
        .when(checked, |el| {
            el.child(
                Icon::new(GLYPH)
                    .style(GLYPH_STYLE)
                    .size_px(GLYPH_SIZE)
                    .color(theme.shade(GLYPH_COLOR)),
            )
        })
}

fn tip(id: ElementId, tooltip: SharedString) -> Tooltip {
    Tooltip::new(id, tooltip).place(TOOLTIP_PLACE).child(
        Icon::new(TOOLTIP_GLYPH)
            .style(TOOLTIP_GLYPH_STYLE)
            .size(TOOLTIP_GLYPH_SIZE),
    )
}

impl RenderOnce for Checkbox {
    fn render(self, _window: &mut Window, app: &mut App) -> impl IntoElement {
        let theme = app.theme();
        let Self {
            id,
            checked,
            label,
            disabled,
            tooltip,
            on_change,
        } = self;
        let handler = on_change.filter(|_| !disabled);
        // settled here so the closure carries a plain bool, not the widget
        let next = !checked;
        let text = if disabled {
            LABEL_DISABLED_COLOR
        } else {
            LABEL_COLOR
        };
        let control = div()
            .id(id)
            .flex()
            .items_center()
            .font_family(FONT_PRIMARY)
            .text_size(px(LABEL_SIZE.px()))
            .line_height(px(LABEL_LINE_HEIGHT))
            .font_weight(FontWeight(f32::from(LABEL_WEIGHT.value())))
            .text_color(rgb(theme.shade(text)))
            .when(disabled, gpui::Styled::cursor_not_allowed)
            .when(!disabled, gpui::Styled::cursor_pointer)
            .child(square(checked, disabled, theme))
            .when_some(label, gpui::ParentElement::child)
            .when_some(handler, |el, handler| {
                el.on_click(move |_, window, cx| handler(&next, window, cx))
            });
        div()
            .flex()
            .items_start()
            .gap(px(TOOLTIP_GAP))
            .child(control)
            .when_some(tooltip, |el, (tip_id, tooltip)| {
                el.child(tip(tip_id, tooltip))
            })
    }
}