1use gpui::{
17 AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
18 prelude::FluentBuilder, px,
19};
20use gpui_kit_semantics::{NodeSpec, Role, Semantic};
21use gpui_kit_theme::{ActiveTheme, Space, Theme, TypeScale};
22
23use crate::display::badge::Tone;
24use crate::display::status::StatusDot;
25use crate::foundation::{Ident, StyledExt};
26use crate::strings::{ActiveStrings, StringKey};
27
28const RAIL: f32 = 16.0;
30
31#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub enum EntryTime {
34 At(SharedString),
36 #[default]
38 Unknown,
39}
40
41impl EntryTime {
42 pub fn as_str(&self) -> &'static str {
43 match self {
44 Self::At(_) => "known",
45 Self::Unknown => "unknown",
46 }
47 }
48
49 pub(crate) fn shown(&self, cx: &App) -> SharedString {
51 match self {
52 Self::At(time) => time.clone(),
53 Self::Unknown => cx.strings().text(StringKey::TimeUnknown),
54 }
55 }
56}
57
58impl From<SharedString> for EntryTime {
59 fn from(value: SharedString) -> Self {
60 Self::At(value)
61 }
62}
63
64impl From<&'static str> for EntryTime {
65 fn from(value: &'static str) -> Self {
66 Self::At(SharedString::new_static(value))
67 }
68}
69
70impl From<String> for EntryTime {
71 fn from(value: String) -> Self {
72 Self::At(SharedString::from(value))
73 }
74}
75
76pub struct TimelineEntry {
78 id: SharedString,
79 time: EntryTime,
80 actor: Option<SharedString>,
81 description: SharedString,
82 tone: Tone,
83 detail: Option<AnyElement>,
84}
85
86impl std::fmt::Debug for TimelineEntry {
87 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 formatter
89 .debug_struct("TimelineEntry")
90 .field("id", &self.id)
91 .field("time", &self.time)
92 .field("actor", &self.actor)
93 .field("tone", &self.tone)
94 .field("has_detail", &self.detail.is_some())
95 .finish()
96 }
97}
98
99impl TimelineEntry {
100 pub fn new(id: impl Into<SharedString>, description: impl Into<SharedString>) -> Self {
101 Self {
102 id: id.into(),
103 time: EntryTime::Unknown,
104 actor: None,
105 description: description.into(),
106 tone: Tone::Neutral,
107 detail: None,
108 }
109 }
110
111 pub fn time(mut self, time: impl Into<EntryTime>) -> Self {
113 self.time = time.into();
114 self
115 }
116
117 pub fn time_unknown(mut self) -> Self {
118 self.time = EntryTime::Unknown;
119 self
120 }
121
122 pub fn actor(mut self, actor: impl Into<SharedString>) -> Self {
123 self.actor = Some(actor.into());
124 self
125 }
126
127 pub fn tone(mut self, tone: Tone) -> Self {
128 self.tone = tone;
129 self
130 }
131
132 pub fn detail(mut self, detail: impl IntoElement) -> Self {
134 self.detail = Some(detail.into_any_element());
135 self
136 }
137
138 pub fn id(&self) -> &SharedString {
139 &self.id
140 }
141}
142
143pub struct TimelineGroup {
145 id: SharedString,
146 label: SharedString,
147 entries: Vec<TimelineEntry>,
148}
149
150impl std::fmt::Debug for TimelineGroup {
151 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 formatter
153 .debug_struct("TimelineGroup")
154 .field("id", &self.id)
155 .field("label", &self.label)
156 .field("entries", &self.entries.len())
157 .finish()
158 }
159}
160
161impl TimelineGroup {
162 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
165 Self {
166 id: id.into(),
167 label: label.into(),
168 entries: Vec::new(),
169 }
170 }
171
172 pub fn entry(mut self, entry: TimelineEntry) -> Self {
173 self.entries.push(entry);
174 self
175 }
176
177 pub fn entries(mut self, entries: impl IntoIterator<Item = TimelineEntry>) -> Self {
178 self.entries.extend(entries);
179 self
180 }
181}
182
183#[derive(IntoElement)]
185pub struct Timeline {
186 ident: Ident,
187 groups: Vec<TimelineGroup>,
188 loose: Vec<TimelineEntry>,
189}
190
191impl std::fmt::Debug for Timeline {
192 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 formatter
194 .debug_struct("Timeline")
195 .field("ident", &self.ident)
196 .field("groups", &self.groups.len())
197 .field("entries", &self.loose.len())
198 .finish()
199 }
200}
201
202impl Timeline {
203 pub fn new(ident: impl Into<Ident>) -> Self {
204 Self {
205 ident: ident.into(),
206 groups: Vec::new(),
207 loose: Vec::new(),
208 }
209 }
210
211 pub fn group(mut self, group: TimelineGroup) -> Self {
212 self.groups.push(group);
213 self
214 }
215
216 pub fn groups(mut self, groups: impl IntoIterator<Item = TimelineGroup>) -> Self {
217 self.groups.extend(groups);
218 self
219 }
220
221 pub fn entries(mut self, entries: impl IntoIterator<Item = TimelineEntry>) -> Self {
223 self.loose.extend(entries);
224 self
225 }
226
227 fn count(&self) -> usize {
228 self.loose.len()
229 + self
230 .groups
231 .iter()
232 .map(|group| group.entries.len())
233 .sum::<usize>()
234 }
235}
236
237impl RenderOnce for Timeline {
238 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
239 let theme = cx.theme().clone();
240 let ident = self.ident.clone();
241 let count = self.count();
242
243 let mut feed = div().column().w_full().gap_token(&theme, Space::Md);
244
245 for (index, entry) in self.loose.into_iter().enumerate() {
246 feed = feed.child(entry_element(&ident, &theme, entry, index + 1 < count, cx));
247 }
248
249 for group in self.groups {
250 let heading = div()
251 .row()
252 .w_full()
253 .gap_token(&theme, Space::Sm)
254 .type_scale(&theme, TypeScale::Caption)
255 .text_color(theme.colors.text_faint)
256 .child(div().w(px(RAIL)).flex_none())
257 .child(group.label.clone())
258 .child(
259 div()
260 .flex_1()
261 .h(px(theme.borders.hairline))
262 .bg(theme.colors.hairline),
263 )
264 .semantic_in(
265 cx,
266 NodeSpec::new(ident.child(group.id.as_ref()).semantic_id(), Role::Heading)
267 .parent(ident.semantic_id())
268 .text(group.label.clone())
269 .value(group.entries.len().to_string()),
270 );
271
272 let last = group.entries.len().saturating_sub(1);
273 let mut section = div().column().w_full().gap_token(&theme, Space::Md);
274 for (index, entry) in group.entries.into_iter().enumerate() {
275 section = section.child(entry_element(&ident, &theme, entry, index < last, cx));
276 }
277
278 feed = feed.child(
279 div()
280 .column()
281 .w_full()
282 .gap_token(&theme, Space::Md)
283 .child(heading)
284 .child(section),
285 );
286 }
287
288 feed.semantic_in(
289 cx,
290 NodeSpec::new(ident.semantic_id(), Role::List).value(count.to_string()),
291 )
292 }
293}
294
295fn entry_element(
297 timeline: &Ident,
298 theme: &Theme,
299 entry: TimelineEntry,
300 continues: bool,
301 cx: &mut App,
302) -> AnyElement {
303 let ident = timeline.child(entry.id.as_ref());
304 let unknown = entry.time == EntryTime::Unknown;
305
306 let rail = div()
307 .w(px(RAIL))
308 .flex_none()
309 .column()
310 .items_center()
311 .child(div().mt(px(4.0)).child(StatusDot::new(entry.tone)))
312 .when(continues, |element| {
313 element.child(
314 div()
315 .mt(px(4.0))
316 .w(px(theme.borders.hairline))
317 .flex_1()
318 .min_h(px(theme.space(Space::Md)))
319 .bg(theme.colors.hairline),
320 )
321 });
322
323 let heading = div()
324 .row()
325 .flex_wrap()
326 .gap_token(theme, Space::Sm)
327 .type_scale(theme, TypeScale::Caption)
328 .child(
329 div()
330 .text_color(if unknown {
331 theme.colors.warning
332 } else {
333 theme.colors.text_muted
334 })
335 .child(entry.time.shown(cx)),
336 )
337 .children(entry.actor.clone().map(|actor| {
338 div()
339 .text_color(theme.colors.text_faint)
340 .child(actor.clone())
341 .semantic_in(
342 cx,
343 NodeSpec::new(ident.child("actor").semantic_id(), Role::Text)
344 .parent(ident.semantic_id())
345 .text(actor),
346 )
347 }));
348
349 div()
350 .row()
351 .items_start()
352 .w_full()
353 .gap_token(theme, Space::Sm)
354 .child(rail)
355 .child(
356 div()
357 .column()
358 .flex_1()
359 .min_w_0()
360 .gap(px(2.0))
361 .child(heading)
362 .child(
363 div()
364 .type_scale(theme, TypeScale::Label)
365 .text_color(theme.colors.text)
366 .child(entry.description.clone()),
367 )
368 .children(entry.detail.map(|detail| {
369 div()
370 .mt_token(theme, Space::Xs)
371 .type_scale(theme, TypeScale::Caption)
372 .text_color(theme.colors.text_muted)
373 .child(detail)
374 })),
375 )
376 .semantic_in(
377 cx,
378 NodeSpec::new(ident.semantic_id(), Role::Row)
379 .parent(timeline.semantic_id())
380 .text(entry.description.clone())
381 .value(match &entry.time {
384 EntryTime::At(time) => time.clone(),
385 EntryTime::Unknown => SharedString::new_static("time unknown"),
386 }),
387 )
388 .into_any_element()
389}