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              a column that does not is a column with a reason"
392        );
393    }
394
395    /// The line is centred **across** its box, and its two indents are measured from the
396    /// top and the bottom rather than from the two ends of a row.
397    #[test]
398    fn a_vertical_rule_is_centred_across_its_box_and_indented_down_it() {
399        let mut scene = Scene::new();
400        Widget::<()>::paint(
401            &VerticalDivider::new()
402                .thickness(2.0)
403                .indent(10.0)
404                .end_indent(6.0),
405            Rect::new(0.0, 0.0, DIVIDER_SPACE, 100.0),
406            Status::default(),
407            &Theme::default(),
408            &mut scene,
409        );
410        let line = scene
411            .primitives()
412            .iter()
413            .find_map(|p| match p {
414                Primitive::Rect { rect, .. } => Some(*rect),
415                _ => None,
416            })
417            .expect("it draws one");
418        assert_eq!(line.width, 2.0, "the thickness is across the row");
419        assert_eq!(line.x, (DIVIDER_SPACE - 2.0) / 2.0, "centred in its box");
420        assert_eq!(line.y, 10.0, "the indent is from the top");
421        assert_eq!(
422            line.height,
423            100.0 - 10.0 - 6.0,
424            "and the end from the bottom"
425        );
426    }
427
428    /// **One theme field for both orientations**, as the reference has it: `space` there,
429    /// [`DividerTheme::height`](crate::widgettheme::DividerTheme::height) here. An
430    /// application that wants its rules tighter says it once and both obey — which is the
431    /// only reason the field is not called `width` on one of them.
432    #[test]
433    fn both_rules_read_the_same_theme_field() {
434        let mut theme = Theme::default();
435        theme.widgets.divider.height = Some(4.0);
436        assert_eq!(
437            Widget::<()>::style_themed(&Divider::new(), &theme).height,
438            Dimension::Length(4.0)
439        );
440        assert_eq!(
441            Widget::<()>::style_themed(&VerticalDivider::new(), &theme).width,
442            Dimension::Length(4.0)
443        );
444    }
445
446    /// **A rule can round its ends** — the reference's `Divider.radius`
447    /// (`divider.dart:68`). A thick rule reads as a bar, and a bar with square ends is the
448    /// only thing left in an interface that still has them.
449    #[test]
450    fn a_rule_can_round_its_ends() {
451        let corners = |divider: &Divider| {
452            let mut scene = Scene::new();
453            Widget::<()>::paint(
454                divider,
455                Rect::new(0.0, 0.0, 200.0, 16.0),
456                Status::default(),
457                &Theme::default(),
458                &mut scene,
459            );
460            scene.primitives().iter().find_map(|p| match p {
461                frus_core::Primitive::Rect { radius, .. } => Some(*radius),
462                _ => None,
463            })
464        };
465
466        assert_eq!(
467            corners(&Divider::new().thickness(6.0).radius(3.0)),
468            Some(frus_core::BorderRadius::uniform(3.0))
469        );
470        assert_eq!(
471            corners(&Divider::new().thickness(6.0)),
472            Some(frus_core::BorderRadius::ZERO),
473            "a hairline is still square, which is what a hairline wants"
474        );
475    }
476
477    fn painted(divider: &Divider, bounds: Rect) -> Option<Rect> {
478        let mut scene = Scene::new();
479        Widget::<()>::paint(
480            divider,
481            bounds,
482            Status::default(),
483            &Theme::default(),
484            &mut scene,
485        );
486        scene.primitives().iter().find_map(|p| match p {
487            Primitive::Rect { rect, .. } => Some(*rect),
488            _ => None,
489        })
490    }
491
492    #[test]
493    fn paints_a_border_line() {
494        let divider = Divider::new();
495        let line = painted(&divider, Rect::new(0.0, 0.0, 120.0, 1.0)).expect("a line");
496        assert_eq!(line.width, 120.0);
497    }
498
499    #[test]
500    fn the_box_and_the_line_are_two_different_measurements() {
501        // The reference's separator takes 16 px of the layout and draws 1 px of line in
502        // the middle of it. Drawing the whole box, which is what this used to do, gives
503        // a 16 px bar.
504        let divider = Divider::new();
505        let style = Widget::<()>::style(&divider);
506        assert_eq!(style.height, Dimension::Length(DIVIDER_SPACE));
507
508        let line = painted(&divider, Rect::new(0.0, 100.0, 200.0, DIVIDER_SPACE)).expect("a line");
509        assert_eq!(line.height, DIVIDER_THICKNESS, "one pixel of line");
510        assert_eq!(
511            line.y,
512            100.0 + (DIVIDER_SPACE - DIVIDER_THICKNESS) / 2.0,
513            "centred in its box, with air on both sides"
514        );
515    }
516
517    #[test]
518    fn a_flush_hairline_is_one_call_away() {
519        let divider = Divider::new().height(1.0);
520        assert_eq!(Widget::<()>::style(&divider).height, Dimension::Length(1.0));
521        let line = painted(&divider, Rect::new(0.0, 0.0, 200.0, 1.0)).expect("a line");
522        assert_eq!((line.y, line.height), (0.0, 1.0), "it fills its own box");
523    }
524
525    #[test]
526    fn the_indents_inset_the_line_and_not_the_box() {
527        let divider = Divider::new().indent(16.0).end_indent(8.0);
528        let line = painted(&divider, Rect::new(10.0, 0.0, 200.0, DIVIDER_SPACE)).expect("a line");
529        assert_eq!(line.x, 26.0, "inset from the leading edge");
530        assert_eq!(line.width, 200.0 - 16.0 - 8.0);
531    }
532
533    #[test]
534    fn a_line_thicker_than_its_box_is_clamped_rather_than_overflowing() {
535        let divider = Divider::new().height(2.0).thickness(10.0);
536        let line = painted(&divider, Rect::new(0.0, 0.0, 100.0, 2.0)).expect("a line");
537        assert_eq!(line.height, 2.0);
538    }
539
540    #[test]
541    fn indents_wider_than_the_box_draw_nothing() {
542        let divider = Divider::new().indent(120.0).end_indent(120.0);
543        assert!(painted(&divider, Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE)).is_none());
544    }
545
546    #[test]
547    fn the_colour_is_the_callers_when_they_give_one() {
548        let mine = Color::rgb8(255, 0, 0);
549        let divider = Divider::new().color(mine);
550        let mut scene = Scene::new();
551        Widget::<()>::paint(
552            &divider,
553            Rect::new(0.0, 0.0, 100.0, DIVIDER_SPACE),
554            Status::default(),
555            &Theme::default(),
556            &mut scene,
557        );
558        let color = scene.primitives().iter().find_map(|p| match p {
559            Primitive::Rect { color, .. } => Some(*color),
560            _ => None,
561        });
562        assert_eq!(color, Some(mine));
563    }
564}