Skip to main content

frus_widgets/
divider.rs

1//! [`Divider`] and [`VerticalDivider`]: a thin separator in the theme's colours, across
2//! a column or down a row.
3
4use frus_core::{BorderRadius, Color, Rect, Scene};
5use frus_layout::{Dimension, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// The extent a divider takes **across** its axis when the caller has not said
12/// otherwise — the line plus the air around it, which is what keeps a separator from
13/// touching the rows it separates. The reference's figure.
14pub const DIVIDER_SPACE: f32 = 16.0;
15/// The line's own thickness, in logical pixels, by default.
16pub const DIVIDER_THICKNESS: f32 = 1.0;
17
18/// A separator: a thin line, centred in a taller box that gives it room to breathe.
19///
20/// The box and the line are **two different measurements**, and conflating them is the
21/// usual mistake: [`Divider::height`] is how much room the separator takes in the
22/// layout, [`Divider::thickness`] is how thick the line drawn inside it is. A divider
23/// with no space around it reads as a border on the row above, not as a separator.
24///
25/// ```ignore
26/// Divider::new()                                   // 16 px of room, a 1 px line
27/// Divider::new().height(1.0)                       // no air: a hairline, flush
28/// Divider::new().indent(16.0)                      // inset, the way a list insets one
29/// Divider::new().thickness(2.0).color(theme.primary)
30/// ```
31pub struct Divider {
32    /// `None` = [`DIVIDER_SPACE`].
33    height: Option<f32>,
34    /// `None` = [`DIVIDER_THICKNESS`].
35    thickness: Option<f32>,
36    /// `None` = the theme's, then none.
37    indent: Option<f32>,
38    /// `None` = the theme's, then none.
39    end_indent: Option<f32>,
40    /// `None` = the theme's discreet outline.
41    color: Option<Color>,
42    /// `None` = square ends, as a hairline wants.
43    radius: Option<BorderRadius>,
44}
45
46impl Divider {
47    /// Creates a separator with the theme's colour and the default spacing.
48    pub fn new() -> Self {
49        Self {
50            height: None,
51            thickness: None,
52            indent: None,
53            end_indent: None,
54            color: None,
55            radius: None,
56        }
57    }
58
59    /// The room the separator takes in the layout, line and air together. Defaults to
60    /// [`DIVIDER_SPACE`].
61    pub fn height(mut self, height: f32) -> Self {
62        self.height = Some(height);
63        self
64    }
65
66    /// The thickness of the line itself. Defaults to [`DIVIDER_THICKNESS`].
67    pub fn thickness(mut self, thickness: f32) -> Self {
68        self.thickness = Some(thickness);
69        self
70    }
71
72    /// Insets the line from the **leading** edge, leaving the box where it is — how a
73    /// list separates rows without cutting through their leading icons.
74    pub fn indent(mut self, indent: f32) -> Self {
75        self.indent = Some(indent);
76        self
77    }
78
79    /// Insets the line from the **trailing** edge.
80    pub fn end_indent(mut self, end_indent: f32) -> Self {
81        self.end_indent = Some(end_indent);
82        self
83    }
84
85    /// **The rule's own corner radius** — the reference's `Divider.radius`
86    /// (`divider.dart:68`). A thick rule reads as a bar, and a bar with square ends is the
87    /// only thing in an interface that still has them.
88    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
89        self.radius = Some(radius.into());
90        self
91    }
92
93    /// Overrides the line's colour. Defaults to the theme's discreet outline.
94    pub fn color(mut self, color: Color) -> Self {
95        self.color = Some(color);
96        self
97    }
98}
99
100impl Default for Divider {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Divider {
107    /// The room it takes, resolved: what the caller said, then the theme, then ours.
108    fn space(&self, theme: Option<&Theme>) -> f32 {
109        self.height
110            .or_else(|| theme.and_then(|t| t.widgets.divider.height))
111            .unwrap_or(DIVIDER_SPACE)
112    }
113}
114
115impl<Msg> Widget<Msg> for Divider {
116    fn style(&self) -> Style {
117        Style {
118            // Automatic width: the parent stretches it (align: stretch).
119            width: Dimension::Auto,
120            height: Dimension::Length(self.space(None)),
121            ..Default::default()
122        }
123    }
124
125    fn style_themed(&self, theme: &Theme) -> Style {
126        Style {
127            height: Dimension::Length(self.space(Some(theme))),
128            ..Widget::<Msg>::style(self)
129        }
130    }
131
132    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
133        &[]
134    }
135
136    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
137        let color = self
138            .color
139            .or(theme.widgets.divider.color)
140            .unwrap_or(theme.scheme.outline_variant);
141        // The line is centred in the box: the space above and below it is the point of
142        // the box being taller than the line. It never draws thicker than its box.
143        let thickness = self
144            .thickness
145            .or(theme.widgets.divider.thickness)
146            .unwrap_or(DIVIDER_THICKNESS)
147            .min(bounds.height);
148        let indent = self.indent.or(theme.widgets.divider.indent).unwrap_or(0.0);
149        let end_indent = self
150            .end_indent
151            .or(theme.widgets.divider.end_indent)
152            .unwrap_or(0.0);
153        let width = (bounds.width - indent - end_indent).max(0.0);
154        if width <= 0.0 || thickness <= 0.0 {
155            return;
156        }
157        let line = Rect::new(
158            bounds.x + indent,
159            bounds.y + (bounds.height - thickness) / 2.0,
160            width,
161            thickness,
162        );
163        match self.radius {
164            Some(radius) => scene.draw_rect(
165                line,
166                color.fade(status.opacity),
167                radius,
168                0.0,
169                Color::TRANSPARENT,
170            ),
171            None => scene.fill_rect(line, color.fade(status.opacity)),
172        }
173    }
174
175    fn on_click(&self) -> Option<Msg> {
176        None
177    }
178}
179
180/// A **vertical** separator: a thin line, centred in a wider box that gives it room.
181///
182/// The horizontal one above and this one are two widgets in the reference too
183/// (`divider.dart:244`), reading the same theme, because the field that means *the room
184/// the separator takes* is called `height` on one and `width` on the other and there is no
185/// honest way to give a single widget one name for it. Everything else is the same
186/// question turned ninety degrees: [`thickness`] is the line, [`indent`] is measured from
187/// the **top** here rather than from the leading edge, and [`end_indent`] from the bottom.
188///
189/// ```ignore
190/// Flex::row()
191///     .child(left_pane)
192///     .child(VerticalDivider::new())     // 16 px of room, a 1 px line
193///     .child(right_pane)
194/// ```
195///
196/// **It takes its row's full height, whatever the row said about alignment.** A row that
197/// centres its children would otherwise give this one the height of its content, and its
198/// content is nothing — so a rule in a centred row would be a rule nobody can see, which
199/// is the first thing anybody hits. It asks for the cross axis on its own behalf
200/// (`align_self`), the way the reference's `Center` inside a `SizedBox` fills whatever
201/// height it is offered.
202///
203/// It still needs a height to fill: a row is as tall as its tallest child, so a rule
204/// between two things that size themselves is as tall as they are, and one standing alone
205/// in a row of nothing else is nothing at all.
206///
207/// [`thickness`]: VerticalDivider::thickness
208/// [`indent`]: VerticalDivider::indent
209/// [`end_indent`]: VerticalDivider::end_indent
210pub struct VerticalDivider {
211    /// `None` = [`DIVIDER_SPACE`].
212    width: Option<f32>,
213    /// `None` = [`DIVIDER_THICKNESS`].
214    thickness: Option<f32>,
215    /// From the **top**. `None` = the theme's, then none.
216    indent: Option<f32>,
217    /// From the **bottom**. `None` = the theme's, then none.
218    end_indent: Option<f32>,
219    /// `None` = the theme's discreet outline.
220    color: Option<Color>,
221    /// `None` = square ends.
222    radius: Option<BorderRadius>,
223}
224
225impl VerticalDivider {
226    /// Creates a vertical separator with the theme's colour and the default spacing.
227    pub fn new() -> Self {
228        Self {
229            width: None,
230            thickness: None,
231            indent: None,
232            end_indent: None,
233            color: None,
234            radius: None,
235        }
236    }
237
238    /// The room the separator takes **across** the row, line and air together. Defaults to
239    /// [`DIVIDER_SPACE`] — the same figure a horizontal one takes, and the same theme
240    /// field, which is why one number covers both.
241    pub fn width(mut self, width: f32) -> Self {
242        self.width = Some(width);
243        self
244    }
245
246    /// The thickness of the line itself. Defaults to [`DIVIDER_THICKNESS`].
247    pub fn thickness(mut self, thickness: f32) -> Self {
248        self.thickness = Some(thickness);
249        self
250    }
251
252    /// Insets the line from the **top**, leaving the box where it is.
253    pub fn indent(mut self, indent: f32) -> Self {
254        self.indent = Some(indent);
255        self
256    }
257
258    /// Insets the line from the **bottom**.
259    pub fn end_indent(mut self, end_indent: f32) -> Self {
260        self.end_indent = Some(end_indent);
261        self
262    }
263
264    /// Overrides the line's colour. Defaults to the theme's discreet outline.
265    pub fn color(mut self, color: Color) -> Self {
266        self.color = Some(color);
267        self
268    }
269
270    /// The rule's own corner radius (`divider.dart:68`).
271    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
272        self.radius = Some(radius.into());
273        self
274    }
275
276    /// The room it takes, resolved: what the caller said, then the theme, then ours.
277    ///
278    /// **The theme field is the horizontal one's**, deliberately. The reference keeps a
279    /// single `DividerThemeData.space` for both orientations, so an application that wants
280    /// its rules tighter says it once.
281    fn space(&self, theme: Option<&Theme>) -> f32 {
282        self.width
283            .or_else(|| theme.and_then(|t| t.widgets.divider.height))
284            .unwrap_or(DIVIDER_SPACE)
285    }
286}
287
288impl Default for VerticalDivider {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl<Msg> Widget<Msg> for VerticalDivider {
295    fn style(&self) -> Style {
296        Style {
297            width: Dimension::Length(self.space(None)),
298            // Automatic height, **stretched on its own say-so**: see the type's note.
299            height: Dimension::Auto,
300            align_self: Some(frus_layout::Align::Stretch),
301            ..Default::default()
302        }
303    }
304
305    fn style_themed(&self, theme: &Theme) -> Style {
306        Style {
307            width: Dimension::Length(self.space(Some(theme))),
308            ..Widget::<Msg>::style(self)
309        }
310    }
311
312    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
313        &[]
314    }
315
316    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
317        let color = self
318            .color
319            .or(theme.widgets.divider.color)
320            .unwrap_or(theme.scheme.outline_variant);
321        let thickness = self
322            .thickness
323            .or(theme.widgets.divider.thickness)
324            .unwrap_or(DIVIDER_THICKNESS)
325            .min(bounds.width);
326        let indent = self.indent.or(theme.widgets.divider.indent).unwrap_or(0.0);
327        let end_indent = self
328            .end_indent
329            .or(theme.widgets.divider.end_indent)
330            .unwrap_or(0.0);
331        let height = (bounds.height - indent - end_indent).max(0.0);
332        if height <= 0.0 || thickness <= 0.0 {
333            return;
334        }
335        let line = Rect::new(
336            bounds.x + (bounds.width - thickness) / 2.0,
337            bounds.y + indent,
338            thickness,
339            height,
340        );
341        match self.radius {
342            Some(radius) => scene.draw_rect(
343                line,
344                color.fade(status.opacity),
345                radius,
346                0.0,
347                Color::TRANSPARENT,
348            ),
349            None => scene.fill_rect(line, color.fade(status.opacity)),
350        }
351    }
352
353    fn on_click(&self) -> Option<Msg> {
354        None
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use frus_core::Primitive;
362
363    /// **The two rules are the same widget turned ninety degrees**, and the box is where
364    /// that shows: one declares a height and lets the column stretch its width, the other
365    /// declares a width and lets the row stretch its height. Get it backwards and a
366    /// vertical rule is an invisible 16-pixel-tall band across the row.
367    #[test]
368    fn a_vertical_rule_declares_its_width_and_takes_the_rows_height() {
369        let across = Widget::<()>::style(&Divider::new());
370        assert_eq!(across.height, Dimension::Length(DIVIDER_SPACE));
371        assert_eq!(across.width, Dimension::Auto);
372
373        let down = Widget::<()>::style(&VerticalDivider::new());
374        assert_eq!(down.width, Dimension::Length(DIVIDER_SPACE));
375        assert_eq!(down.height, Dimension::Auto);
376    }
377
378    /// **A rule in a centred row is still a full-height rule.** It asks for the cross axis
379    /// itself, because the alternative is a widget whose first use is invisible: a row that
380    /// centres its children hands this one the height of its content, and its content is
381    /// nothing.
382    #[test]
383    fn a_vertical_rule_fills_its_row_whatever_the_row_said() {
384        assert_eq!(
385            Widget::<()>::style(&VerticalDivider::new()).align_self,
386            Some(frus_layout::Align::Stretch)
387        );
388        assert_eq!(
389            Widget::<()>::style(&Divider::new()).align_self,
390            None,
391            "the horizontal one has no such problem: a column stretches it by default and \
392             a column that does not is a column with a reason"
393        );
394    }
395
396    /// The line is centred **across** its box, and its two indents are measured from the
397    /// top and the bottom rather than from the two ends of a row.
398    #[test]
399    fn a_vertical_rule_is_centred_across_its_box_and_indented_down_it() {
400        let mut scene = Scene::new();
401        Widget::<()>::paint(
402            &VerticalDivider::new()
403                .thickness(2.0)
404                .indent(10.0)
405                .end_indent(6.0),
406            Rect::new(0.0, 0.0, DIVIDER_SPACE, 100.0),
407            Status::default(),
408            &Theme::default(),
409            &mut scene,
410        );
411        let line = scene
412            .primitives()
413            .iter()
414            .find_map(|p| match p {
415                Primitive::Rect { rect, .. } => Some(*rect),
416                _ => None,
417            })
418            .expect("it draws one");
419        assert_eq!(line.width, 2.0, "the thickness is across the row");
420        assert_eq!(line.x, (DIVIDER_SPACE - 2.0) / 2.0, "centred in its box");
421        assert_eq!(line.y, 10.0, "the indent is from the top");
422        assert_eq!(
423            line.height,
424            100.0 - 10.0 - 6.0,
425            "and the end from the bottom"
426        );
427    }
428
429    /// **One theme field for both orientations**, as the reference has it: `space` there,
430    /// [`DividerTheme::height`](crate::widgettheme::DividerTheme::height) here. An
431    /// application that wants its rules tighter says it once and both obey — which is the
432    /// only reason the field is not called `width` on one of them.
433    #[test]
434    fn both_rules_read_the_same_theme_field() {
435        let mut theme = Theme::default();
436        theme.widgets.divider.height = Some(4.0);
437        assert_eq!(
438            Widget::<()>::style_themed(&Divider::new(), &theme).height,
439            Dimension::Length(4.0)
440        );
441        assert_eq!(
442            Widget::<()>::style_themed(&VerticalDivider::new(), &theme).width,
443            Dimension::Length(4.0)
444        );
445    }
446
447    /// **A rule can round its ends** — the reference's `Divider.radius`
448    /// (`divider.dart:68`). A thick rule reads as a bar, and a bar with square ends is the
449    /// only thing left in an interface that still has them.
450    #[test]
451    fn a_rule_can_round_its_ends() {
452        let corners = |divider: &Divider| {
453            let mut scene = Scene::new();
454            Widget::<()>::paint(
455                divider,
456                Rect::new(0.0, 0.0, 200.0, 16.0),
457                Status::default(),
458                &Theme::default(),
459                &mut scene,
460            );
461            scene.primitives().iter().find_map(|p| match p {
462                frus_core::Primitive::Rect { radius, .. } => Some(*radius),
463                _ => None,
464            })
465        };
466
467        assert_eq!(
468            corners(&Divider::new().thickness(6.0).radius(3.0)),
469            Some(frus_core::BorderRadius::uniform(3.0))
470        );
471        assert_eq!(
472            corners(&Divider::new().thickness(6.0)),
473            Some(frus_core::BorderRadius::ZERO),
474            "a hairline is still square, which is what a hairline wants"
475        );
476    }
477
478    fn painted(divider: &Divider, bounds: Rect) -> Option<Rect> {
479        let mut scene = Scene::new();
480        Widget::<()>::paint(
481            divider,
482            bounds,
483            Status::default(),
484            &Theme::default(),
485            &mut scene,
486        );
487        scene.primitives().iter().find_map(|p| match p {
488            Primitive::Rect { rect, .. } => Some(*rect),
489            _ => None,
490        })
491    }
492
493    #[test]
494    fn paints_a_border_line() {
495        let divider = Divider::new();
496        let line = painted(&divider, Rect::new(0.0, 0.0, 120.0, 1.0)).expect("a line");
497        assert_eq!(line.width, 120.0);
498    }
499
500    #[test]
501    fn the_box_and_the_line_are_two_different_measurements() {
502        // The reference's separator takes 16 px of the layout and draws 1 px of line in
503        // the middle of it. Drawing the whole box, which is what this used to do, gives
504        // a 16 px bar.
505        let divider = Divider::new();
506        let style = Widget::<()>::style(&divider);
507        assert_eq!(style.height, Dimension::Length(DIVIDER_SPACE));
508
509        let line = painted(&divider, Rect::new(0.0, 100.0, 200.0, DIVIDER_SPACE)).expect("a line");
510        assert_eq!(line.height, DIVIDER_THICKNESS, "one pixel of line");
511        assert_eq!(
512            line.y,
513            100.0 + (DIVIDER_SPACE - DIVIDER_THICKNESS) / 2.0,
514            "centred in its box, with air on both sides"
515        );
516    }
517
518    #[test]
519    fn a_flush_hairline_is_one_call_away() {
520        let divider = Divider::new().height(1.0);
521        assert_eq!(Widget::<()>::style(&divider).height, Dimension::Length(1.0));
522        let line = painted(&divider, Rect::new(0.0, 0.0, 200.0, 1.0)).expect("a line");
523        assert_eq!((line.y, line.height), (0.0, 1.0), "it fills its own box");
524    }
525
526    #[test]
527    fn the_indents_inset_the_line_and_not_the_box() {
528        let divider = Divider::new().indent(16.0).end_indent(8.0);
529        let line = painted(&divider, Rect::new(10.0, 0.0, 200.0, DIVIDER_SPACE)).expect("a line");
530        assert_eq!(line.x, 26.0, "inset from the leading edge");
531        assert_eq!(line.width, 200.0 - 16.0 - 8.0);
532    }
533
534    #[test]
535    fn a_line_thicker_than_its_box_is_clamped_rather_than_overflowing() {
536        let divider = Divider::new().height(2.0).thickness(10.0);
537        let line = painted(&divider, Rect::new(0.0, 0.0, 100.0, 2.0)).expect("a line");
538        assert_eq!(line.height, 2.0);
539    }
540
541    #[test]
542    fn indents_wider_than_the_box_draw_nothing() {
543        let divider = Divider::new().indent(120.0).end_indent(120.0);
544        assert!(painted(&divider, Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE)).is_none());
545    }
546
547    #[test]
548    fn the_colour_is_the_callers_when_they_give_one() {
549        let mine = Color::rgb8(255, 0, 0);
550        let divider = Divider::new().color(mine);
551        let mut scene = Scene::new();
552        Widget::<()>::paint(
553            &divider,
554            Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE),
555            Status::default(),
556            &Theme::default(),
557            &mut scene,
558        );
559        let color = scene.primitives().iter().find_map(|p| match p {
560            Primitive::Rect { color, .. } => Some(*color),
561            _ => None,
562        });
563        assert_eq!(color, Some(mine));
564    }
565}