Skip to main content

herogpui_components/
separator.rs

1//! Separator — port of `@heroui/separator` (v3, formerly `Divider`).
2//!
3//! `variant` pairs with the surrounding [`Surface`](crate::surface::Surface)
4//! prominence so the line stays visible as the container gets more prominent.
5
6use gpui::{
7    div, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
8    RenderOnce, Styled, Window,
9};
10use herogpui_core::Orientation;
11use herogpui_theme::ActiveTheme;
12
13/// Visual variant of a separator.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15pub enum SeparatorVariant {
16    /// `--separator`
17    #[default]
18    Default,
19    /// `color-mix(in oklab, surface 85%, surface-foreground 15%)`
20    Secondary,
21    /// `color-mix(in oklab, surface 81%, surface-foreground 19%)`
22    Tertiary,
23}
24
25impl SeparatorVariant {
26    /// Every separator variant, in declaration order.
27    pub const ALL: [SeparatorVariant; 3] = [
28        SeparatorVariant::Default,
29        SeparatorVariant::Secondary,
30        SeparatorVariant::Tertiary,
31    ];
32
33    /// A human-readable label for this variant.
34    pub fn label(self) -> &'static str {
35        match self {
36            SeparatorVariant::Default => "Default",
37            SeparatorVariant::Secondary => "Secondary",
38            SeparatorVariant::Tertiary => "Tertiary",
39        }
40    }
41}
42
43/// HeroUI Separator.
44#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
45#[derive(IntoElement)]
46pub struct Separator {
47    orientation: Orientation,
48    variant: SeparatorVariant,
49    inset_y: Pixels,
50    inset_x: Pixels,
51    /// Set by [`Toolbar::separator`](crate::toolbar::Toolbar::separator) for
52    /// `.toolbar`'s own descendant rules, which halve whichever separator
53    /// crosses the bar's flow and centre it. See [`Separator::in_toolbar`].
54    in_toolbar: bool,
55    id: Option<ElementId>,
56    /// The `sx` slot, refined over the root style at the end of render.
57    sx: Option<Box<gpui::StyleRefinement>>,
58}
59
60impl Separator {
61    /// Creates a horizontal separator.
62    pub fn new() -> Self {
63        Self {
64            orientation: Orientation::Horizontal,
65            variant: SeparatorVariant::default(),
66            inset_y: gpui::px(0.),
67            inset_x: gpui::px(0.),
68            in_toolbar: false,
69            id: None,
70            sx: None,
71        }
72    }
73
74    /// Names this instance. AccessKit 0.24 has no `Role::Separator`, so a
75    /// named separator still produces no accessibility node — the id is here
76    /// so a later AccessKit bump can claim the role without a public-API
77    /// change.
78    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
79        self.id = Some(id.into());
80        self
81    }
82
83    /// Applies `.toolbar`'s descendant rules for a separator inside a bar:
84    /// `.separator--vertical` becomes `h-1/2 self-center` and
85    /// `.separator--horizontal` becomes `w-1/2 justify-self-center`, so the
86    /// rule crossing the bar's flow is half its cross size and centred rather
87    /// than running the bar's whole edge.
88    ///
89    /// v3 spells this as a descendant selector, so *any* separator inside a
90    /// toolbar picks it up. A [`Toolbar`](crate::toolbar::Toolbar) holds
91    /// type-erased children and cannot reach into one to restyle it, so the
92    /// bar builds its own separators instead — reach for
93    /// [`Toolbar::separator`](crate::toolbar::Toolbar::separator) rather than
94    /// passing a hand-built `Separator` as a child.
95    pub(crate) fn in_toolbar(mut self) -> Self {
96        self.in_toolbar = true;
97        self
98    }
99
100    /// Sets the separator orientation.
101    pub fn orientation(mut self, orientation: Orientation) -> Self {
102        self.orientation = orientation;
103        self
104    }
105
106    /// Sets the separator variant.
107    pub fn variant(mut self, variant: SeparatorVariant) -> Self {
108        self.variant = variant;
109        self
110    }
111
112    /// The one slot for caller-owned low-level styling: GPUI's styling methods
113    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
114    /// applied to the separator's root element after every value the variant
115    /// and the active theme chose, so they win.
116    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
117        crate::util::refine_sx(&mut self.sx, style);
118        self
119    }
120
121    /// Vertical inset. gpui has no `className`, so the margin v3 sets with
122    /// `my-*` is a builder here.
123    pub fn my(mut self, v: impl Into<Pixels>) -> Self {
124        self.inset_y = v.into();
125        self
126    }
127
128    /// Horizontal inset — the `mx-*` counterpart of [`Separator::my`].
129    pub fn mx(mut self, v: impl Into<Pixels>) -> Self {
130        self.inset_x = v.into();
131        self
132    }
133}
134
135impl Default for Separator {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141impl RenderOnce for Separator {
142    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
143        let colors = cx.colors();
144        let weight = cx.layout().border_width;
145        let color = match self.variant {
146            SeparatorVariant::Default => colors.separator,
147            SeparatorVariant::Secondary => colors.separator_secondary(),
148            SeparatorVariant::Tertiary => colors.separator_tertiary(),
149        };
150
151        // `separator.tsx` renders a childless RAC `Separator`: v3 has no
152        // content-bearing mode (v3.2.6 deleted the never-applied
153        // `.separator__container` / `__line` / `__content` rules), so the
154        // "With Content" example places separators *between* blocks.
155        let radius = crate::util::hairline_radius(cx);
156
157        if self.in_toolbar {
158            // `.toolbar` halves the rule that crosses its flow and centres it:
159            // an 18px tick in a 36px bar, not a line down its whole edge.
160            //
161            // The half-length box is positioned inside a transparent full-size
162            // slot rather than sized directly, because a percentage
163            // main/cross size against a bar whose own size comes from its
164            // controls has nothing definite to resolve against. This is the
165            // same construction `ButtonGroup` draws its member separators
166            // with, and the only one in this codebase proven to land on the
167            // measured 25%/50% geometry.
168            let slot = div()
169                .relative()
170                .my(self.inset_y)
171                .mx(self.inset_x)
172                .flex_shrink_0()
173                .debug_selector(|| "toolbar-separator".to_owned());
174            let mark = div()
175                .absolute()
176                .rounded(radius)
177                .bg(color)
178                .debug_selector(|| "toolbar-separator-mark".to_owned());
179            let slot = match self.orientation {
180                Orientation::Horizontal => slot.w_full().h(weight).child(
181                    mark.left(gpui::relative(0.25))
182                        .w(gpui::relative(0.5))
183                        .h(weight),
184                ),
185                Orientation::Vertical => slot.self_stretch().min_h(gpui::px(8.)).w(weight).child(
186                    mark.top(gpui::relative(0.25))
187                        .h(gpui::relative(0.5))
188                        .w(weight),
189                ),
190            };
191            return finish_separator(crate::util::apply_sx(slot, &self.sx), self.id);
192        }
193
194        let el = div()
195            .my(self.inset_y)
196            .mx(self.inset_x)
197            .flex_shrink_0()
198            .rounded(radius)
199            .bg(color);
200
201        let el = match self.orientation {
202            Orientation::Horizontal => el.w_full().h(weight),
203            // `.separator--vertical` is `min-h-2`: a vertical rule between
204            // two inline items still draws when its row is shorter.
205            Orientation::Vertical => el.h_full().min_h(gpui::px(8.)).w(weight),
206        };
207        finish_separator(crate::util::apply_sx(el, &self.sx), self.id)
208    }
209}
210
211fn finish_separator(el: gpui::Div, id: Option<ElementId>) -> AnyElement {
212    match id {
213        Some(id) => el.id(id).into_any_element(),
214        None => el.into_any_element(),
215    }
216}
217
218crate::util::impl_component_styled!(Separator);