1use 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
11const ROW_H: f32 = 56.0;
13const LINE_X: f32 = 8.0;
14const DOT: f32 = 10.0;
15const TEXT_X: f32 = 28.0;
16const TEXT_PAD_Y: f32 = 8.0;
18const TEXT_GAP: f32 = 3.0;
20
21fn 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
33fn 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
40struct Event {
42 title: String,
43 detail: String,
44 title_text_style: Option<TextStyle>,
45 detail_text_style: Option<TextStyle>,
46}
47
48impl Event {
49 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 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 scene.fill_rect(
88 Rect::new(cx - 1.0, bounds.y, 2.0, bounds.height),
89 theme.border.fade(o),
90 );
91 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 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
123pub 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 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 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 #[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 #[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 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}