Skip to main content

iced_cube/primitives/
separator.rs

1//! Thin lines that divide groups of content, optionally with inline text.
2
3use iced::widget::{column, row, rule, text};
4use iced::{Alignment, Element, Theme};
5
6use crate::theme::{Tokens, space, text_size};
7
8/// Direction of the line.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
10pub enum Orientation {
11    #[default]
12    Horizontal,
13    Vertical,
14}
15
16impl Orientation {
17    pub const ALL: [Orientation; 2] = [Orientation::Horizontal, Orientation::Vertical];
18}
19
20/// A separator builder. Convert it into an [`Element`] to render.
21///
22/// Horizontal separators fill the available width, vertical ones the
23/// available height.
24#[derive(Debug)]
25pub struct Separator<'a> {
26    orientation: Orientation,
27    label: Option<text::Fragment<'a>>,
28}
29
30/// Creates a horizontal separator.
31pub fn separator<'a>() -> Separator<'a> {
32    Separator {
33        orientation: Orientation::Horizontal,
34        label: None,
35    }
36}
37
38/// Creates a vertical separator.
39pub fn vertical_separator<'a>() -> Separator<'a> {
40    separator().orientation(Orientation::Vertical)
41}
42
43impl<'a> Separator<'a> {
44    pub fn orientation(mut self, orientation: Orientation) -> Self {
45        self.orientation = orientation;
46        self
47    }
48
49    /// Shows short text, such as "or", in the middle of the line.
50    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
51        self.label = Some(label.into_fragment());
52        self
53    }
54}
55
56impl<'a, Message: 'a> From<Separator<'a>> for Element<'a, Message> {
57    fn from(separator: Separator<'a>) -> Self {
58        let line = move || -> rule::Rule<'a, Theme> {
59            let line = match separator.orientation {
60                Orientation::Horizontal => rule::horizontal(1),
61                Orientation::Vertical => rule::vertical(1),
62            };
63            line.style(|theme| style(&Tokens::of(theme)))
64        };
65
66        let Some(label) = separator.label else {
67            return line().into();
68        };
69
70        let label = text(label).size(text_size::XS).style(|theme| text::Style {
71            color: Some(label_colour(&Tokens::of(theme))),
72        });
73
74        match separator.orientation {
75            Orientation::Horizontal => row![line(), label, line()]
76                .spacing(space::SM)
77                .align_y(Alignment::Center)
78                .into(),
79            Orientation::Vertical => column![line(), label, line()]
80                .spacing(space::SM)
81                .align_x(Alignment::Center)
82                .into(),
83        }
84    }
85}
86
87/// The iced rule style for a separator line.
88pub fn style(tokens: &Tokens) -> rule::Style {
89    rule::Style {
90        color: tokens.border,
91        radius: 0.0.into(),
92        fill_mode: rule::FillMode::Full,
93        snap: true,
94    }
95}
96
97/// Colour of the inline text.
98pub fn label_colour(tokens: &Tokens) -> iced::Color {
99    tokens.muted_foreground
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::theme::{dark, light};
106
107    #[test]
108    fn default_builder_is_horizontal_without_label() {
109        let s = separator();
110        assert_eq!(s.orientation, Orientation::Horizontal);
111        assert!(s.label.is_none());
112    }
113
114    #[test]
115    fn vertical_constructor_sets_orientation() {
116        assert_eq!(vertical_separator().orientation, Orientation::Vertical);
117        for orientation in Orientation::ALL {
118            assert_eq!(
119                separator().orientation(orientation).orientation,
120                orientation
121            );
122        }
123    }
124
125    #[test]
126    fn label_is_kept() {
127        let s = separator().label("or");
128        assert_eq!(s.label.as_deref(), Some("or"));
129    }
130
131    #[test]
132    fn line_uses_border_token_and_fills() {
133        for theme in [light(), dark()] {
134            let tokens = Tokens::of(&theme);
135            let style = style(&tokens);
136            assert_eq!(style.color, tokens.border);
137            assert_eq!(style.fill_mode, rule::FillMode::Full);
138            assert_ne!(style.color, tokens.background);
139        }
140    }
141
142    #[test]
143    fn label_is_muted_and_readable() {
144        for theme in [light(), dark()] {
145            let tokens = Tokens::of(&theme);
146            assert_eq!(label_colour(&tokens), tokens.muted_foreground);
147            assert_ne!(label_colour(&tokens), tokens.background);
148        }
149    }
150}