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);