Skip to main content

qframe/widgets/
divider.rs

1//! Dividers: separation by space and tone, never by a drawn line.
2
3use crate::geometry::{Rect, Size};
4use crate::text;
5use crate::widget::{MeasureCx, PaintCx, Widget};
6
7/// A pause between groups of content.
8///
9/// A plain divider is one empty row: space is the quietest separator and it never competes with
10/// the content. A caption turns it into a quiet heading for what follows; a band paints the
11/// divider in a tone one step away from its surroundings, the way surfaces separate everywhere
12/// else. It never draws a line character.
13///
14/// Style keys: `divider` (`band`), `divider-label` (`fg`, `bold`).
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Divider {
17    space: u16,
18    label: Option<String>,
19    band: bool,
20    vertical: bool,
21}
22
23impl Divider {
24    /// One empty row.
25    #[must_use]
26    pub fn new() -> Self {
27        Self { space: 1, label: None, band: false, vertical: false }
28    }
29
30    /// Rows (or columns, when vertical) of space; 1 by default.
31    #[must_use]
32    pub fn space(mut self, cells: u16) -> Self {
33        self.space = cells;
34        self
35    }
36
37    /// A caption on its own row after the space, introducing the content below it. Horizontal
38    /// dividers only; write it the way panel titles are written, e.g. `"STOPPED"`.
39    #[must_use]
40    pub fn label(mut self, label: impl Into<String>) -> Self {
41        self.label = Some(label.into());
42        self
43    }
44
45    /// Paints the divider in the band tone instead of leaving it transparent.
46    #[must_use]
47    pub fn band(mut self) -> Self {
48        self.band = true;
49        self
50    }
51
52    /// Separates side by side content with `space` columns. Give it `.fill_height()` so it spans
53    /// the row.
54    #[must_use]
55    pub fn vertical(mut self) -> Self {
56        self.vertical = true;
57        self
58    }
59
60    fn caption(&self) -> Option<&str> {
61        self.label.as_deref().filter(|_| !self.vertical)
62    }
63}
64
65impl Default for Divider {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl<Msg: 'static> Widget<Msg> for Divider {
72    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
73        if self.vertical {
74            // One row; `fill_height` stretches it across the row it separates.
75            return Size::new(self.space, 1).min(available);
76        }
77        let label_row = u16::from(self.caption().is_some());
78        Size::new(available.width, self.space.saturating_add(label_row)).min(available)
79    }
80
81    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
82        if self.band {
83            let style = cx.style("divider", None, &[]);
84            let band = style.color("band").unwrap_or_else(|| cx.color("raised"));
85            cx.clear(area, band);
86        }
87        if let Some(label) = self.caption()
88            && area.height > 0
89        {
90            let mut style = cx.style("divider-label", None, &[]).text();
91            style.bg = None;
92            let y = area.bottom() - 1;
93            let shown = text::truncate(label, area.width).into_owned();
94            cx.text(area.x, y, &shown, style, area.width);
95        }
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::runtime::{App, Command, Harness};
103    use crate::widget::View;
104    use crate::widgets::Text;
105
106    struct Demo(Divider);
107
108    impl App for Demo {
109        type Msg = ();
110        fn update(&mut self, _: ()) -> Command<()> {
111            Command::none()
112        }
113        fn view(&self, ui: &mut View<'_, ()>) {
114            ui.column(|ui| {
115                ui.add(Text::new("web"));
116                ui.add(self.0.clone());
117                ui.add(Text::new("db"));
118            })
119            .fill();
120        }
121    }
122
123    #[test]
124    fn plain_divider_is_space() {
125        let h = Harness::new(Demo(Divider::new()), 12, 4);
126        assert_eq!(h.screen(), "web\n\ndb\n\n");
127    }
128
129    #[test]
130    fn caption_sits_after_the_space_and_truncates() {
131        let h = Harness::new(Demo(Divider::new().label("STOPPED SERVICES")), 10, 5);
132        assert_eq!(h.screen(), "web\n\nSTOPPED S…\ndb\n\n");
133        assert_eq!(h.fg(0, 2), h.env().theme().color("muted"));
134    }
135
136    #[test]
137    fn band_paints_a_tone_without_characters() {
138        let h = Harness::new(Demo(Divider::new().band().space(2)), 8, 5);
139        assert_eq!(h.screen(), "web\n\n\ndb\n\n");
140        let theme = h.env().theme();
141        let band = theme.color("surface").expect("token").mix(theme.color("raised").expect("token"), 0.6);
142        assert_eq!(h.bg(3, 1), Some(band));
143        assert_eq!(h.bg(3, 2), Some(band));
144    }
145
146    struct Columns;
147
148    impl App for Columns {
149        type Msg = ();
150        fn update(&mut self, _: ()) -> Command<()> {
151            Command::none()
152        }
153        fn view(&self, ui: &mut View<'_, ()>) {
154            ui.row(|ui| {
155                ui.add(Text::new("cpu"));
156                ui.add(Divider::new().vertical().space(2).band().label("ignored")).fill_height();
157                ui.add(Text::new("mem"));
158            })
159            .fill();
160        }
161    }
162
163    #[test]
164    fn vertical_divider_is_a_column_gap() {
165        let h = Harness::new(Columns, 12, 2);
166        assert_eq!(h.screen(), "cpu  mem\n\n");
167        assert_eq!(h.bg(3, 1), h.bg(4, 0));
168    }
169
170    #[test]
171    fn a_huge_space_with_a_caption_fills_the_area() {
172        let h = Harness::new(Demo(Divider::new().space(u16::MAX).label("STOPPED")), 10, 4);
173        assert_eq!(h.screen(), "web\n\n\nSTOPPED\n", "the caption keeps the last row");
174    }
175}