Skip to main content

frus_widgets/
timeline.rs

1//! [`Timeline`]: a vertical chronology — events joined by a line, each marked with a
2//! dot.
3
4use frus_core::{Color, Point, Rect, ResolvedTextStyle, Scene, TextStyle};
5use frus_layout::{Dimension, FlexDirection, Style};
6
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// The floor of a row. A row grows past it when its two lines ask for more.
12const ROW_H: f32 = 56.0;
13const LINE_X: f32 = 8.0;
14const DOT: f32 = 10.0;
15const TEXT_X: f32 = 28.0;
16/// The room above the title and below the detail.
17const TEXT_PAD_Y: f32 = 8.0;
18/// The room between the two lines.
19const TEXT_GAP: f32 = 3.0;
20
21/// The title's style: what the caller said, else what the theme says, else the
22/// reference's — an entry reads as a list tile, whose title is `bodyLarge`.
23///
24/// **Resolved once**, so that the number the row is measured with is the number the glyphs
25/// are drawn at. Resolving is the single place the reader's font setting is applied
26/// (milestone 403); a size that never passes through it is a size the reader cannot change.
27fn title_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
28    over.or(theme.and_then(|t| t.widgets.timeline.title_text_style))
29        .unwrap_or_else(|| crate::theme::type_scale(theme).body_large)
30        .resolved()
31}
32
33/// The detail line's style — a list tile's subtitle is `bodyMedium`. See [`title_style`].
34fn detail_style(over: Option<TextStyle>, theme: Option<&Theme>) -> ResolvedTextStyle {
35    over.or(theme.and_then(|t| t.widgets.timeline.detail_text_style))
36        .unwrap_or_else(|| crate::theme::type_scale(theme).body_medium)
37        .resolved()
38}
39
40/// One event on the timeline: a dot, a line and the texts.
41struct Event {
42    title: String,
43    detail: String,
44    title_text_style: Option<TextStyle>,
45    detail_text_style: Option<TextStyle>,
46}
47
48impl Event {
49    /// The two styles this row is measured and drawn with.
50    fn styles(&self, theme: Option<&Theme>) -> (ResolvedTextStyle, ResolvedTextStyle) {
51        (
52            title_style(self.title_text_style, theme),
53            detail_style(self.detail_text_style, theme),
54        )
55    }
56
57    fn sizing(&self, theme: Option<&Theme>) -> Style {
58        // The floor, or what the two lines actually need. A row fixed at `ROW_H` puts the
59        // detail of the next entry over the title of this one the moment a reader turns
60        // the type up, which is the whole reason a height is asked for rather than stated.
61        let (title, detail) = self.styles(theme);
62        let needed = TEXT_PAD_Y * 2.0 + title.line_height() + TEXT_GAP + detail.line_height();
63        Style {
64            height: Dimension::Length(ROW_H.max(needed.ceil())),
65            ..Default::default()
66        }
67    }
68}
69
70impl<Msg> Widget<Msg> for Event {
71    fn style(&self) -> Style {
72        self.sizing(None)
73    }
74
75    fn style_themed(&self, theme: &Theme) -> Style {
76        self.sizing(Some(theme))
77    }
78
79    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
80        &[]
81    }
82
83    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
84        let o = status.opacity;
85        let cx = bounds.x + LINE_X;
86        // A continuous line, crossing the whole row so that the rows join up.
87        scene.fill_rect(
88            Rect::new(cx - 1.0, bounds.y, 2.0, bounds.height),
89            theme.border.fade(o),
90        );
91        // Point.
92        let cy = bounds.y + bounds.height * 0.5;
93        scene.draw_rect(
94            Rect::new(cx - DOT * 0.5, cy - DOT * 0.5, DOT, DOT),
95            theme.primary.fade(o),
96            DOT * 0.5,
97            0.0,
98            Color::TRANSPARENT,
99        );
100        // Title and detail. The second follows the first's **own** line box: a fixed
101        // offset would be right for one type size and wrong for every other.
102        let (title, detail) = self.styles(Some(theme));
103        let ty = bounds.y + TEXT_PAD_Y;
104        scene.text(
105            Point::new(bounds.x + TEXT_X, ty),
106            self.title.clone(),
107            &title,
108            theme.on_surface.fade(o),
109        );
110        scene.text(
111            Point::new(bounds.x + TEXT_X, ty + title.line_height() + TEXT_GAP),
112            self.detail.clone(),
113            &detail,
114            theme.muted.fade(o),
115        );
116    }
117
118    fn on_click(&self) -> Option<Msg> {
119        None
120    }
121}
122
123/// A vertical chronology.
124pub struct Timeline<Msg = crate::callback::Callback> {
125    events: Vec<(String, String)>,
126    title_text_style: Option<TextStyle>,
127    detail_text_style: Option<TextStyle>,
128    children: Vec<Box<dyn Widget<Msg>>>,
129}
130
131impl<Msg> Timeline<Msg> {
132    /// Creates an empty timeline.
133    pub fn new() -> Self {
134        Self {
135            events: Vec::new(),
136            title_text_style: None,
137            detail_text_style: None,
138            children: Vec::new(),
139        }
140    }
141
142    /// Adds an event, title and detail, oldest to most recent.
143    pub fn event(mut self, title: impl Into<String>, detail: impl Into<String>) -> Self {
144        self.events.push((title.into(), detail.into()));
145        self.rebuild();
146        self
147    }
148
149    /// The entries' headings, over the theme's and the reference's.
150    #[must_use]
151    pub fn title_text_style(mut self, style: TextStyle) -> Self {
152        self.title_text_style = Some(style);
153        self.rebuild();
154        self
155    }
156
157    /// The line under each heading, over the theme's and the reference's.
158    #[must_use]
159    pub fn detail_text_style(mut self, style: TextStyle) -> Self {
160        self.detail_text_style = Some(style);
161        self.rebuild();
162        self
163    }
164
165    /// Carries the current styles into every row, so that the builders are
166    /// order-independent.
167    fn rebuild(&mut self) {
168        self.children = self
169            .events
170            .iter()
171            .map(|(title, detail)| {
172                Box::new(Event {
173                    title: title.clone(),
174                    detail: detail.clone(),
175                    title_text_style: self.title_text_style,
176                    detail_text_style: self.detail_text_style,
177                }) as Box<dyn Widget<Msg>>
178            })
179            .collect();
180    }
181}
182
183impl<Msg> Default for Timeline<Msg> {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl<Msg> Widget<Msg> for Timeline<Msg> {
190    fn style(&self) -> Style {
191        Style {
192            flex_direction: FlexDirection::Column,
193            ..Default::default()
194        }
195    }
196
197    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
198        &self.children
199    }
200
201    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
202
203    fn on_click(&self) -> Option<Msg> {
204        None
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::{build_ui, Runtime, Size};
212    use frus_core::Primitive;
213
214    #[test]
215    fn events_paint_dots_and_text() {
216        let timeline = Timeline::<()>::new()
217            .event("Milestone 1", "window")
218            .event("Milestone 2", "layout");
219        assert_eq!(Widget::<()>::children(&timeline).len(), 2);
220
221        let ui = build_ui(
222            &timeline,
223            Size::new(300.0, 200.0),
224            &Runtime::default(),
225            &Theme::default(),
226        );
227        let has = |t: &str| {
228            ui.scene()
229                .primitives()
230                .iter()
231                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
232        };
233        assert!(has("Milestone 1") && has("Milestone 2"));
234    }
235}