Skip to main content

liora_components/
timeline.rs

1use gpui::{
2    AnyElement, App, Hsla, IntoElement, RenderOnce, SharedString, Window, div, prelude::*, px,
3};
4use liora_core::Config;
5use liora_icons::Icon;
6use liora_icons_lucide::IconName;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum TimelineMode {
10    #[default]
11    Left,
12    Right,
13    Alternate,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TimelineTone {
18    Primary,
19    Success,
20    Warning,
21    Danger,
22    Info,
23}
24
25pub struct TimelineItem {
26    pub timestamp: Option<SharedString>,
27    pub content: AnyElement,
28    pub color: Option<Hsla>,
29    pub tone: Option<TimelineTone>,
30    pub icon: Option<IconName>,
31    pub hollow: bool,
32    pub hide_timestamp: bool,
33    pub placement: TimelinePlacement,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum TimelinePlacement {
38    #[default]
39    Top,
40    Bottom,
41}
42
43pub struct Timeline {
44    items: Vec<TimelineItem>,
45    reverse: bool,
46    mode: TimelineMode,
47}
48
49impl TimelineItem {
50    pub fn new() -> Self {
51        Self {
52            timestamp: None,
53            content: div().into_any_element(),
54            color: None,
55            tone: None,
56            icon: None,
57            hollow: false,
58            hide_timestamp: false,
59            placement: TimelinePlacement::Bottom,
60        }
61    }
62
63    pub fn timestamp(mut self, t: impl Into<SharedString>) -> Self {
64        self.timestamp = Some(t.into());
65        self
66    }
67
68    pub fn content(mut self, content: impl IntoElement) -> Self {
69        self.content = content.into_any_element();
70        self
71    }
72
73    pub fn color(mut self, c: Hsla) -> Self {
74        self.color = Some(c);
75        self.tone = None;
76        self
77    }
78
79    pub fn tone(mut self, tone: TimelineTone) -> Self {
80        self.tone = Some(tone);
81        self.color = None;
82        self
83    }
84
85    pub fn primary(self) -> Self {
86        self.tone(TimelineTone::Primary)
87    }
88
89    pub fn success(self) -> Self {
90        self.tone(TimelineTone::Success)
91    }
92
93    pub fn warning(self) -> Self {
94        self.tone(TimelineTone::Warning)
95    }
96
97    pub fn danger(self) -> Self {
98        self.tone(TimelineTone::Danger)
99    }
100
101    pub fn info(self) -> Self {
102        self.tone(TimelineTone::Info)
103    }
104
105    pub fn icon(mut self, icon: IconName) -> Self {
106        self.icon = Some(icon);
107        self
108    }
109
110    pub fn hollow(mut self, h: bool) -> Self {
111        self.hollow = h;
112        self
113    }
114
115    pub fn placement(mut self, p: TimelinePlacement) -> Self {
116        self.placement = p;
117        self
118    }
119
120    pub fn hide_timestamp(mut self, hide: bool) -> Self {
121        self.hide_timestamp = hide;
122        self
123    }
124}
125
126impl Timeline {
127    pub fn new() -> Self {
128        Self {
129            items: vec![],
130            reverse: false,
131            mode: TimelineMode::Left,
132        }
133    }
134
135    pub fn reverse(mut self, r: bool) -> Self {
136        self.reverse = r;
137        self
138    }
139
140    pub fn mode(mut self, m: TimelineMode) -> Self {
141        self.mode = m;
142        self
143    }
144
145    pub fn item(mut self, item: TimelineItem) -> Self {
146        self.items.push(item);
147        self
148    }
149}
150
151impl RenderOnce for Timeline {
152    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
153        let theme = cx.global::<Config>().theme.clone();
154        let mut items = self.items;
155        if self.reverse {
156            items.reverse();
157        }
158        let items_count = items.len();
159
160        div()
161            .flex()
162            .flex_col()
163            .w_full()
164            .children(items.into_iter().enumerate().map(|(i, item)| {
165                let is_last = i == items_count - 1;
166                let dot_color = item.color.unwrap_or_else(|| match item.tone {
167                    Some(TimelineTone::Primary) => theme.primary.base,
168                    Some(TimelineTone::Success) => theme.success.base,
169                    Some(TimelineTone::Warning) => theme.warning.base,
170                    Some(TimelineTone::Danger) => theme.danger.base,
171                    Some(TimelineTone::Info) => theme.info.base,
172                    None => theme.neutral.border,
173                });
174                let text_color = theme.neutral.text_2;
175                let timestamp_color = theme.neutral.text_3;
176
177                div()
178                    .flex()
179                    .flex_row()
180                    .gap_3()
181                    .relative()
182                    .child(
183                        // Left: Axis & Node
184                        div()
185                            .flex()
186                            .flex_col()
187                            .items_center()
188                            .w(px(20.0))
189                            .child(
190                                // Node
191                                div()
192                                    .flex()
193                                    .items_center()
194                                    .justify_center()
195                                    .w(px(12.0))
196                                    .h(px(12.0))
197                                    .mt(px(4.0))
198                                    .rounded_full()
199                                    .bg(if item.hollow {
200                                        theme.neutral.card
201                                    } else {
202                                        dot_color
203                                    })
204                                    .border_2()
205                                    .border_color(dot_color)
206                                    .when_some(item.icon, |s, icon| {
207                                        // If icon, use icon instead of dot
208                                        s.size(px(20.0))
209                                            .mt(px(0.0))
210                                            .bg(gpui::transparent_black())
211                                            .border_0()
212                                            .child(Icon::new(icon).size(px(14.0)).color(dot_color))
213                                    }),
214                            )
215                            .when(!is_last, |s| {
216                                s.child(
217                                    // Vertical Line
218                                    div().flex_1().w(px(2.0)).bg(theme.neutral.border),
219                                )
220                            }),
221                    )
222                    .child(
223                        // Right: Content & Timestamp
224                        div()
225                            .flex()
226                            .flex_col()
227                            .pb_6()
228                            .flex_1()
229                            .when(
230                                item.placement == TimelinePlacement::Top && !item.hide_timestamp,
231                                |s| {
232                                    s.when_some(item.timestamp.clone(), |s, t| {
233                                        s.child(
234                                            div()
235                                                .text_xs()
236                                                .text_color(timestamp_color)
237                                                .mb_1()
238                                                .child(t),
239                                        )
240                                    })
241                                },
242                            )
243                            .child(div().text_sm().text_color(text_color).child(item.content))
244                            .when(
245                                item.placement == TimelinePlacement::Bottom && !item.hide_timestamp,
246                                |s| {
247                                    s.when_some(item.timestamp, |s, t| {
248                                        s.child(
249                                            div()
250                                                .text_xs()
251                                                .text_color(timestamp_color)
252                                                .mt_2()
253                                                .child(t),
254                                        )
255                                    })
256                                },
257                            ),
258                    )
259            }))
260    }
261}
262
263impl IntoElement for Timeline {
264    type Element = gpui::Component<Self>;
265    fn into_element(self) -> Self::Element {
266        gpui::Component::new(self)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn timeline_tone_helpers_track_semantic_tone() {
276        let item = TimelineItem::new().success();
277        assert_eq!(item.tone, Some(TimelineTone::Success));
278        assert!(item.color.is_none());
279
280        let custom = TimelineItem::new().success().color(gpui::red());
281        assert_eq!(custom.tone, None);
282        assert_eq!(custom.color, Some(gpui::red()));
283    }
284}