Skip to main content

fret_ui_kit/primitives/
separator.rs

1use fret_core::{Px, SemanticsOrientation, SemanticsRole};
2use fret_ui::element::{AnyElement, ContainerProps, Length, SemanticsDecoration, SizeStyle};
3use fret_ui::{ElementContext, Theme, UiHost};
4
5use crate::LayoutRefinement;
6use crate::declarative::style as decl_style;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum SeparatorOrientation {
10    #[default]
11    Horizontal,
12    Vertical,
13}
14
15#[derive(Debug, Clone)]
16pub struct Separator {
17    orientation: SeparatorOrientation,
18    thickness: Option<Px>,
19    flex_stretch_cross_axis: bool,
20    decorative: bool,
21    layout: LayoutRefinement,
22}
23
24impl Separator {
25    pub fn new() -> Self {
26        Self {
27            orientation: SeparatorOrientation::default(),
28            thickness: None,
29            flex_stretch_cross_axis: false,
30            decorative: false,
31            layout: LayoutRefinement::default(),
32        }
33    }
34
35    pub fn orientation(mut self, orientation: SeparatorOrientation) -> Self {
36        self.orientation = orientation;
37        self
38    }
39
40    pub fn thickness(mut self, thickness: Px) -> Self {
41        self.thickness = Some(thickness);
42        self
43    }
44
45    pub fn flex_stretch_cross_axis(mut self, stretch: bool) -> Self {
46        self.flex_stretch_cross_axis = stretch;
47        self
48    }
49
50    pub fn decorative(mut self, decorative: bool) -> Self {
51        self.decorative = decorative;
52        self
53    }
54
55    pub fn refine_layout(mut self, layout: LayoutRefinement) -> Self {
56        self.layout = self.layout.merge(layout);
57        self
58    }
59
60    #[track_caller]
61    pub fn into_element<H: UiHost>(self, cx: &mut ElementContext<'_, H>) -> AnyElement {
62        let (border, thickness, mut layout) = {
63            let theme = Theme::global(&*cx.app);
64
65            let border = theme
66                .color_by_key("border")
67                .unwrap_or_else(|| theme.color_token("border"));
68            let thickness = self.thickness.unwrap_or_else(|| {
69                theme
70                    .metric_by_key("component.separator.px")
71                    .unwrap_or(Px(1.0))
72            });
73            let layout = decl_style::layout_style(theme, self.layout);
74
75            (border, thickness, layout)
76        };
77        match self.orientation {
78            SeparatorOrientation::Horizontal => {
79                layout.size = SizeStyle {
80                    width: Length::Fill,
81                    height: Length::Px(thickness),
82                    min_height: Some(Length::Px(thickness)),
83                    max_height: Some(Length::Px(thickness)),
84                    ..layout.size
85                };
86            }
87            SeparatorOrientation::Vertical => {
88                layout.size = SizeStyle {
89                    width: Length::Px(thickness),
90                    // In shadcn/radix recipes the vertical separator is typically `self-stretch`
91                    // inside a flex row. Using `Fill` maps to `height: 100%`, which does not
92                    // resolve in an auto-height containing block. Keeping the height `Auto`
93                    // allows `align-items: stretch` to produce the desired outcome.
94                    height: if self.flex_stretch_cross_axis {
95                        Length::Auto
96                    } else {
97                        Length::Fill
98                    },
99                    min_width: Some(Length::Px(thickness)),
100                    max_width: Some(Length::Px(thickness)),
101                    ..layout.size
102                };
103            }
104        }
105
106        let mut element = cx.container(
107            ContainerProps {
108                layout,
109                background: Some(border),
110                ..Default::default()
111            },
112            |_cx| Vec::new(),
113        );
114
115        let decoration = if self.decorative {
116            SemanticsDecoration::default().hidden(true)
117        } else {
118            let mut semantics = SemanticsDecoration::default().role(SemanticsRole::Separator);
119            if self.orientation == SeparatorOrientation::Vertical {
120                semantics = semantics.orientation(SemanticsOrientation::Vertical);
121            }
122            semantics
123        };
124        element = element.attach_semantics(decoration);
125
126        element
127    }
128}
129
130#[track_caller]
131pub fn separator<H: UiHost>(cx: &mut ElementContext<'_, H>) -> AnyElement {
132    Separator::new().into_element(cx)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    use fret_app::App;
140    use fret_core::{AppWindowId, Point, Rect, Size};
141    use fret_ui::element::{ElementKind, Length};
142
143    fn bounds_200x100() -> Rect {
144        Rect::new(
145            Point::new(Px(0.0), Px(0.0)),
146            Size::new(Px(200.0), Px(100.0)),
147        )
148    }
149
150    #[test]
151    fn separator_defaults_to_semantic_horizontal_rule() {
152        let window = AppWindowId::default();
153        let mut app = App::new();
154
155        let element =
156            fret_ui::elements::with_element_cx(&mut app, window, bounds_200x100(), "test", |cx| {
157                Separator::new().into_element(cx)
158            });
159
160        let ElementKind::Container(props) = &element.kind else {
161            panic!("expected Separator to render a container");
162        };
163        assert_eq!(props.layout.size.width, Length::Fill);
164        assert_eq!(props.layout.size.height, Length::Px(Px(1.0)));
165
166        let decoration = element
167            .semantics_decoration
168            .as_ref()
169            .expect("expected separator semantics decoration");
170        assert_eq!(decoration.role, Some(SemanticsRole::Separator));
171        assert_eq!(decoration.hidden, None);
172        assert_eq!(decoration.orientation, None);
173    }
174
175    #[test]
176    fn decorative_separator_hides_from_semantics() {
177        let window = AppWindowId::default();
178        let mut app = App::new();
179
180        let element =
181            fret_ui::elements::with_element_cx(&mut app, window, bounds_200x100(), "test", |cx| {
182                Separator::new().decorative(true).into_element(cx)
183            });
184
185        let decoration = element
186            .semantics_decoration
187            .as_ref()
188            .expect("expected decorative separator semantics decoration");
189        assert_eq!(decoration.hidden, Some(true));
190        assert_eq!(decoration.role, None);
191        assert_eq!(decoration.orientation, None);
192    }
193
194    #[test]
195    fn vertical_separator_can_expose_vertical_semantics() {
196        let window = AppWindowId::default();
197        let mut app = App::new();
198
199        let element =
200            fret_ui::elements::with_element_cx(&mut app, window, bounds_200x100(), "test", |cx| {
201                Separator::new()
202                    .orientation(SeparatorOrientation::Vertical)
203                    .flex_stretch_cross_axis(true)
204                    .into_element(cx)
205            });
206
207        let ElementKind::Container(props) = &element.kind else {
208            panic!("expected vertical Separator to render a container");
209        };
210        assert_eq!(props.layout.size.width, Length::Px(Px(1.0)));
211        assert_eq!(props.layout.size.height, Length::Auto);
212
213        let decoration = element
214            .semantics_decoration
215            .as_ref()
216            .expect("expected vertical separator semantics decoration");
217        assert_eq!(decoration.role, Some(SemanticsRole::Separator));
218        assert_eq!(decoration.orientation, Some(SemanticsOrientation::Vertical));
219        assert_eq!(decoration.hidden, None);
220    }
221}