herogpui-components 0.10.1

HeroUI-style component library for GPUI
Documentation
//! Separator — port of `@heroui/separator` (v3, formerly `Divider`).
//!
//! `variant` pairs with the surrounding [`Surface`](crate::surface::Surface)
//! prominence so the line stays visible as the container gets more prominent.

use gpui::{
    div, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
    RenderOnce, Styled, Window,
};
use herogpui_core::Orientation;
use herogpui_theme::ActiveTheme;

/// Visual variant of a separator.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SeparatorVariant {
    /// `--separator`
    #[default]
    Default,
    /// `color-mix(in oklab, surface 85%, surface-foreground 15%)`
    Secondary,
    /// `color-mix(in oklab, surface 81%, surface-foreground 19%)`
    Tertiary,
}

impl SeparatorVariant {
    pub const ALL: [SeparatorVariant; 3] = [
        SeparatorVariant::Default,
        SeparatorVariant::Secondary,
        SeparatorVariant::Tertiary,
    ];

    pub fn label(self) -> &'static str {
        match self {
            SeparatorVariant::Default => "Default",
            SeparatorVariant::Secondary => "Secondary",
            SeparatorVariant::Tertiary => "Tertiary",
        }
    }
}

/// HeroUI Separator.
#[derive(IntoElement)]
pub struct Separator {
    orientation: Orientation,
    variant: SeparatorVariant,
    inset_y: Pixels,
    inset_x: Pixels,
    /// Set by [`Toolbar::separator`](crate::toolbar::Toolbar::separator) for
    /// `.toolbar`'s own descendant rules, which halve whichever separator
    /// crosses the bar's flow and centre it. See [`Separator::in_toolbar`].
    in_toolbar: bool,
    /// v3 composes content *inside* a separator (`<Separator>OR</Separator>`),
    /// which turns it into `.separator__container`: a line, the content, a line.
    content: Vec<AnyElement>,
    id: Option<ElementId>,
    /// The `sx` slot, refined over the root style at the end of render.
    sx: Option<Box<gpui::StyleRefinement>>,
}

impl Separator {
    pub fn new() -> Self {
        Self {
            orientation: Orientation::Horizontal,
            variant: SeparatorVariant::default(),
            inset_y: gpui::px(0.),
            inset_x: gpui::px(0.),
            in_toolbar: false,
            content: Vec::new(),
            id: None,
            sx: None,
        }
    }

    /// Names this instance. AccessKit 0.24 has no `Role::Separator`, so a
    /// named separator still produces no accessibility node — the id is here
    /// so a later AccessKit bump can claim the role without a public-API
    /// change.
    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
        self.id = Some(id.into());
        self
    }

    /// Applies `.toolbar`'s descendant rules for a separator inside a bar:
    /// `.separator--vertical` becomes `h-1/2 self-center` and
    /// `.separator--horizontal` becomes `w-1/2 justify-self-center`, so the
    /// rule crossing the bar's flow is half its cross size and centred rather
    /// than running the bar's whole edge.
    ///
    /// v3 spells this as a descendant selector, so *any* separator inside a
    /// toolbar picks it up. A [`Toolbar`](crate::toolbar::Toolbar) holds
    /// type-erased children and cannot reach into one to restyle it, so the
    /// bar builds its own separators instead — reach for
    /// [`Toolbar::separator`](crate::toolbar::Toolbar::separator) rather than
    /// passing a hand-built `Separator` as a child.
    pub(crate) fn in_toolbar(mut self) -> Self {
        self.in_toolbar = true;
        self
    }

    pub fn orientation(mut self, orientation: Orientation) -> Self {
        self.orientation = orientation;
        self
    }

    pub fn variant(mut self, variant: SeparatorVariant) -> Self {
        self.variant = variant;
        self
    }

    /// The one slot for caller-owned low-level styling: GPUI's styling methods
    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
    /// applied to the separator's root element after every value the variant
    /// and the active theme chose, so they win.
    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
        self.sx = Some(crate::util::capture_sx(style));
        self
    }

    /// Vertical inset. gpui has no `className`, so the margin v3 sets with
    /// `my-*` is a builder here.
    pub fn my(mut self, v: impl Into<Pixels>) -> Self {
        self.inset_y = v.into();
        self
    }

    /// Horizontal inset — the `mx-*` counterpart of [`Separator::my`].
    pub fn mx(mut self, v: impl Into<Pixels>) -> Self {
        self.inset_x = v.into();
        self
    }
}

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

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

impl RenderOnce for Separator {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let colors = cx.colors();
        let weight = cx.layout().border_width;
        let color = match self.variant {
            SeparatorVariant::Default => colors.separator,
            SeparatorVariant::Secondary => colors.separator_secondary(),
            SeparatorVariant::Tertiary => colors.separator_tertiary(),
        };

        let horizontal = self.orientation == Orientation::Horizontal;
        let radius = crate::util::hairline_radius(cx);
        let line = move || {
            let el = div().flex_shrink_0().rounded(radius).bg(color);
            if horizontal {
                // `.separator__line` is `shrink-0 grow`, so a line beside
                // content takes the space the content leaves.
                el.h(weight).flex_grow(1.)
            } else {
                // `.separator--vertical` is `min-h-2`: a vertical rule between
                // two inline items still draws when its row is shorter.
                el.w(weight).min_h(gpui::px(8.)).flex_grow(1.)
            }
        };

        if !self.content.is_empty() {
            // `.separator__container` is `flex items-center gap-3`, row when
            // horizontal and a centred column when vertical.
            let mut el = div()
                .my(self.inset_y)
                .mx(self.inset_x)
                .flex()
                .items_center()
                .gap(gpui::px(12.));
            el = if horizontal {
                el.w_full().flex_row()
            } else {
                el.h_full().flex_col().justify_center()
            };
            let el = el
                .child(line())
                .child(
                    // `.separator__content` is a centred, non-wrapping run of
                    // `--muted` text.
                    div()
                        .flex()
                        .items_center()
                        .justify_center()
                        .flex_shrink_0()
                        .text_center()
                        .whitespace_nowrap()
                        .text_color(colors.muted)
                        .children(self.content),
                )
                .child(line());
            return finish_separator(crate::util::apply_sx(el, &self.sx), self.id);
        }

        if self.in_toolbar {
            // `.toolbar` halves the rule that crosses its flow and centres it:
            // an 18px tick in a 36px bar, not a line down its whole edge.
            //
            // The half-length box is positioned inside a transparent full-size
            // slot rather than sized directly, because a percentage
            // main/cross size against a bar whose own size comes from its
            // controls has nothing definite to resolve against. This is the
            // same construction `ButtonGroup` draws its member separators
            // with, and the only one in this codebase proven to land on the
            // measured 25%/50% geometry.
            let slot = div()
                .relative()
                .my(self.inset_y)
                .mx(self.inset_x)
                .flex_shrink_0()
                .debug_selector(|| "toolbar-separator".to_owned());
            let mark = div()
                .absolute()
                .rounded(radius)
                .bg(color)
                .debug_selector(|| "toolbar-separator-mark".to_owned());
            let slot = match self.orientation {
                Orientation::Horizontal => slot.w_full().h(weight).child(
                    mark.left(gpui::relative(0.25))
                        .w(gpui::relative(0.5))
                        .h(weight),
                ),
                Orientation::Vertical => slot.self_stretch().min_h(gpui::px(8.)).w(weight).child(
                    mark.top(gpui::relative(0.25))
                        .h(gpui::relative(0.5))
                        .w(weight),
                ),
            };
            return finish_separator(crate::util::apply_sx(slot, &self.sx), self.id);
        }

        let el = div()
            .my(self.inset_y)
            .mx(self.inset_x)
            .flex_shrink_0()
            .rounded(radius)
            .bg(color);

        let el = match self.orientation {
            Orientation::Horizontal => el.w_full().h(weight),
            Orientation::Vertical => el.h_full().min_h(gpui::px(8.)).w(weight),
        };
        finish_separator(crate::util::apply_sx(el, &self.sx), self.id)
    }
}

fn finish_separator(el: gpui::Div, id: Option<ElementId>) -> AnyElement {
    match id {
        Some(id) => el.id(id).into_any_element(),
        None => el.into_any_element(),
    }
}