fluidattacks-designs 0.11.0

Fluid Attacks design system, GPUI variant: desktop widgets over the shared tokens
//! `Container` — the web's generic box: a styled div plus the `scroll` prop.
//! Style delegates to gpui's [`Styled`], so every gpui style method works here.
//!
//! A block box, as a `div` is on the web: children stack at their content size
//! and a scrolling one overflows rather than being squeezed. gpui defaults to
//! flex, where flexbox drops the automatic minimum size of any child hiding its
//! own overflow; `.flex()` opts back in.

use fluidattacks_designs_domain::container::Scroll;
use gpui::prelude::FluentBuilder as _;
use gpui::{
    div, px, AnyElement, App, Div, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
    ScrollHandle, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window,
};

#[derive(IntoElement)]
pub struct Container {
    base: Div,
    scroll: Scroll,
    handle: Option<(SharedString, ScrollHandle)>,
    children: Vec<AnyElement>,
}

impl Container {
    #[must_use]
    pub fn new() -> Self {
        Self {
            // block, as a `div` is on the web; gpui would default to flex
            base: div().block(),
            scroll: Scroll::None,
            handle: None,
            children: Vec::new(),
        }
    }

    /// Let the named axes scroll rather than clip (web `scroll`).
    ///
    /// The offset is the app's to own, so hold one `handle` per scroll area and
    /// hand it over here. `name` is a string because each bar derives its own
    /// id from it, and element state cannot be keyed twice.
    #[must_use]
    pub fn scroll(
        mut self,
        name: impl Into<SharedString>,
        axes: Scroll,
        handle: &ScrollHandle,
    ) -> Self {
        self.scroll = axes;
        self.handle = Some((name.into(), handle.clone()));
        self
    }
}

impl Default for Container {
    fn default() -> Self {
        Self::new()
    }
}

impl Styled for Container {
    fn style(&mut self) -> &mut StyleRefinement {
        self.base.style()
    }
}

impl ParentElement for Container {
    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
        self.children.extend(elements);
    }
}

impl RenderOnce for Container {
    fn render(mut self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
        let scroll = self.scroll;
        let Some((name, handle)) = self.handle.take() else {
            return self.base.children(self.children).into_any_element();
        };
        // the bars anchor to the nearest positioned ancestor, so geometry moves
        // out to the wrapper and the scrolling box fills it exactly
        let geometry = {
            let style = self.base.style();
            StyleRefinement {
                position: style.position.take(),
                inset: core::mem::take(&mut style.inset),
                size: core::mem::take(&mut style.size),
                min_size: core::mem::take(&mut style.min_size),
                max_size: core::mem::take(&mut style.max_size),
                margin: core::mem::take(&mut style.margin),
                flex_grow: style.flex_grow.take(),
                flex_shrink: style.flex_shrink.take(),
                flex_basis: style.flex_basis.take(),
                ..StyleRefinement::default()
            }
        };
        // Grown inside a column rather than sized at 100%, which is what lets
        // one node do both jobs: taffy 0.12, in with gpui-ce, stopped falling
        // back to the content size for a percentage against a parent that has
        // no size of its own, so a box meant to be sized by its rows collapsed
        // and scrolled what would have fitted. A basis of auto contributes that
        // content height upward; growing fills a wrapper that has a height of
        // its own; and the minimums are what let it shrink and scroll there.
        let content = self
            .base
            .id(SharedString::from(format!("{name}-content")))
            .flex_grow(1.0)
            .min_w(px(0.0))
            .min_h(px(0.0))
            .when(scroll.x(), StatefulInteractiveElement::overflow_x_scroll)
            .when(scroll.y(), StatefulInteractiveElement::overflow_y_scroll)
            .track_scroll(&handle)
            .children(self.children);
        let mut wrapper = div();
        *wrapper.style() = geometry;
        // a column, so the box above grows and shrinks on the axis it scrolls
        let wrapper = wrapper.flex().flex_col();
        // each bar keeps its drag grip in element state, so the two axes cannot
        // share one id
        wrapper
            .relative()
            .child(content)
            .when(scroll.y(), |el| {
                el.child(crate::Scrollbar::vertical(
                    SharedString::from(format!("{name}-scroll-y")),
                    &handle,
                ))
            })
            .when(scroll.x(), |el| {
                el.child(crate::Scrollbar::horizontal(
                    SharedString::from(format!("{name}-scroll-x")),
                    &handle,
                ))
            })
            .into_any_element()
    }
}

#[cfg(test)]
mod tests {
    /// The render impl, where the scrolling box is built. Stops at this module,
    /// whose own text would otherwise answer the scan.
    fn rendered() -> &'static str {
        let source = include_str!("container.rs");
        let start = source
            .find("impl RenderOnce for Container")
            .expect("the scan found no render impl, so it is not reading this file");
        let body = source.split_at(start).1;
        let end = body
            .find("#[cfg(test)]")
            .expect("the scan found no test module, so it is not reading this file");
        body.split_at(end).0
    }

    #[test]
    fn the_scrolling_box_is_grown_inside_a_column() {
        let rendered = rendered();
        for shape in [
            ".flex_col()",
            ".flex_grow(1.0)",
            ".min_w(px(0.0))",
            ".min_h(px(0.0))",
        ] {
            assert!(rendered.contains(shape), "the scrolling box lost `{shape}`");
        }
    }

    #[test]
    fn the_scrolling_box_never_claims_a_percentage_of_its_parent() {
        let rendered = rendered();
        // taffy 0.12 stopped falling back to the content size for these, so a
        // box whose parent has no height of its own collapses
        for sized in [".size_full()", ".h_full()", ".w_full()"] {
            assert!(!rendered.contains(sized), "`{sized}` collapses the box");
        }
    }
}