Skip to main content

frus_widgets/
breadcrumb.rs

1//! [`Breadcrumb`]: a trail of clickable segments, the last of which is the current
2//! page.
3
4use frus_core::{Point, Rect, ResolvedTextStyle, Scene, TextStyle};
5use frus_layout::{Align, Dimension, FlexDirection, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// The style the segments are drawn in: what the caller said, else what the theme says,
12/// else `bodyMedium`.
13///
14/// The reference has no breadcrumb, so this one is argued rather than read: a trail is a
15/// **secondary** line of navigation above the page's own content, and it should not read
16/// louder than what it leads to.
17///
18/// **Resolved once**, so that the number the box is measured with is the number the glyphs
19/// are drawn at. Resolving is the single place the reader's font setting is applied
20/// (milestone 403); a size that never passes through it is a size the reader cannot change.
21fn label_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
22    over.or(theme.and_then(|t| t.widgets.breadcrumb.text_style))
23        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
24        .resolved()
25}
26
27/// One segment: a clickable link, the current page, or a separator.
28struct Crumb<Msg> {
29    label: String,
30    /// `Some` is a clickable link; `None` is the current page or a separator.
31    message: Option<Msg>,
32    separator: bool,
33    text_style: Option<TextStyle>,
34}
35
36impl<Msg> Crumb<Msg> {
37    fn sizing(&self, theme: Option<&Theme>) -> Style {
38        let measured =
39            frus_text::measure_resolved(&self.label, &label_style(self.text_style, theme));
40        Style {
41            width: Dimension::Length(measured.width.ceil()),
42            height: Dimension::Length(measured.height.ceil()),
43            ..Default::default()
44        }
45    }
46}
47
48impl<Msg: Clone> Widget<Msg> for Crumb<Msg> {
49    fn style(&self) -> Style {
50        self.sizing(None)
51    }
52
53    fn style_themed(&self, theme: &Theme) -> Style {
54        self.sizing(Some(theme))
55    }
56
57    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
58        &[]
59    }
60
61    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
62        let o = status.opacity;
63        let color = if self.separator {
64            theme.muted
65        } else if self.message.is_some() {
66            // A link: discreet, lightening on hover.
67            theme.muted.lerp(theme.on_surface, status.hover_progress)
68        } else {
69            // The current page.
70            theme.on_surface
71        };
72        scene.text(
73            Point::new(bounds.x, bounds.y),
74            self.label.clone(),
75            &label_style(self.text_style, Some(theme)),
76            color.fade(o),
77        );
78    }
79
80    fn on_click(&self) -> Option<Msg> {
81        self.message.clone()
82    }
83
84    fn focusable(&self) -> bool {
85        self.message.is_some()
86    }
87}
88
89/// A controlled breadcrumb trail.
90pub struct Breadcrumb<Msg = crate::callback::Callback> {
91    on_select: Box<dyn Fn(usize) -> Msg>,
92    labels: Vec<String>,
93    text_style: Option<TextStyle>,
94    children: Vec<Box<dyn Widget<Msg>>>,
95}
96
97impl<Msg: Clone + 'static> Breadcrumb<Msg> {
98    /// Creates a breadcrumb; `on_select(i)` is emitted when the i-th segment is clicked.
99    pub fn new<R: crate::callback::IntoMsg<Msg>>(on_select: impl Fn(usize) -> R + 'static) -> Self {
100        Self {
101            on_select: Box::new(crate::callback::handler1(on_select)),
102            labels: Vec::new(),
103            text_style: None,
104            children: Vec::new(),
105        }
106    }
107
108    /// The segments' type, over the theme's and the framework's.
109    #[must_use]
110    pub fn text_style(mut self, style: TextStyle) -> Self {
111        self.text_style = Some(style);
112        self.rebuild();
113        self
114    }
115
116    /// Adds a segment (at the end of the trail).
117    pub fn crumb(mut self, label: impl Into<String>) -> Self {
118        self.labels.push(label.into());
119        self.rebuild();
120        self
121    }
122
123    fn rebuild(&mut self) {
124        let last = self.labels.len().saturating_sub(1);
125        let mut children: Vec<Box<dyn Widget<Msg>>> = Vec::new();
126        for (i, label) in self.labels.iter().enumerate() {
127            if i > 0 {
128                children.push(Box::new(Crumb {
129                    label: "›".to_string(),
130                    message: None,
131                    separator: true,
132                    text_style: self.text_style,
133                }));
134            }
135            children.push(Box::new(Crumb {
136                label: label.clone(),
137                message: if i == last {
138                    None
139                } else {
140                    Some((self.on_select)(i))
141                },
142                separator: false,
143                text_style: self.text_style,
144            }));
145        }
146        self.children = children;
147    }
148}
149
150impl<Msg: Clone> Widget<Msg> for Breadcrumb<Msg> {
151    fn style(&self) -> Style {
152        Style {
153            flex_direction: FlexDirection::Row,
154            align: Align::Center,
155            gap: 6.0,
156            ..Default::default()
157        }
158    }
159
160    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
161        &self.children
162    }
163
164    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
165
166    fn on_click(&self) -> Option<Msg> {
167        None
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[derive(Clone, Debug, PartialEq)]
176    enum Msg {
177        Go(usize),
178    }
179
180    #[test]
181    fn links_click_but_current_does_not() {
182        let bc = Breadcrumb::new(Msg::Go)
183            .crumb("Home")
184            .crumb("Settings")
185            .crumb("Advanced");
186        let children = Widget::<Msg>::children(&bc);
187        // 3 segments plus 2 separators makes 5 children.
188        assert_eq!(children.len(), 5);
189        // Child 0 = "Home" (a link) → Go(0).
190        assert_eq!(children[0].on_click(), Some(Msg::Go(0)));
191        // The last one, "Advanced", is current and so not clickable.
192        assert_eq!(children[4].on_click(), None);
193    }
194}