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 div()
185 .flex()
186 .flex_col()
187 .items_center()
188 .w(px(20.0))
189 .child(
190 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 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 div().flex_1().w(px(2.0)).bg(theme.neutral.border),
219 )
220 }),
221 )
222 .child(
223 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}