Skip to main content

gpui_kit/display/
timeline.rs

1//! A chronological feed of what happened, and who did it.
2//!
3//! # The timeline does not know what time it is
4//!
5//! Every time here is a string the caller already formatted, and so is every
6//! day heading. Turning an instant into words is calendar, time-zone and
7//! locale work: whoever owns the clock owns the wording, and a component that
8//! formatted a timestamp itself would be inventing a locale for its host. The
9//! adapter that does the formatting sits above this component and hands it
10//! finished strings, which is the whole seam.
11//!
12//! An entry whose time nobody knows says so. It is not quietly floated to the
13//! top or dropped to the bottom, because either would be a claim about when it
14//! happened.
15
16use 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
28/// How wide the column holding the rail and its dots is.
29const RAIL: f32 = 16.0;
30
31/// When an entry happened, as far as anyone knows.
32#[derive(Debug, Clone, PartialEq, Eq, Default)]
33pub enum EntryTime {
34    /// A time the caller has already put into words.
35    At(SharedString),
36    /// Nobody knows when this happened, and the entry says so.
37    #[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    /// The words a reader sees, including the ones for a time nobody knows.
50    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
76/// One thing that happened.
77pub 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    /// The time, already in the words the host chose.
112    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    /// Anything the entry carries beyond one line, such as a diff or a reason.
133    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
143/// A run of entries under one heading, usually a day.
144pub 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    /// `label` is the heading in the caller's words — "Today", "12 March" —
163    /// because the timeline does not name days either.
164    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/// A chronological feed, optionally divided into days.
184#[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    /// Entries with no heading over them, kept in the order they arrived.
222    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
295/// One entry, with the rail running past its dot when something follows it.
296fn 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                // The time is the one fact a reader would otherwise have to
382                // take off the pixels, and an unknown one says so by name.
383                .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}