use gpui::{
AnyElement, App, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Space, Theme, TypeScale};
use crate::display::badge::Tone;
use crate::display::status::StatusDot;
use crate::foundation::{Ident, StyledExt};
use crate::strings::{ActiveStrings, StringKey};
const RAIL: f32 = 16.0;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum EntryTime {
At(SharedString),
#[default]
Unknown,
}
impl EntryTime {
pub fn as_str(&self) -> &'static str {
match self {
Self::At(_) => "known",
Self::Unknown => "unknown",
}
}
pub(crate) fn shown(&self, cx: &App) -> SharedString {
match self {
Self::At(time) => time.clone(),
Self::Unknown => cx.strings().text(StringKey::TimeUnknown),
}
}
}
impl From<SharedString> for EntryTime {
fn from(value: SharedString) -> Self {
Self::At(value)
}
}
impl From<&'static str> for EntryTime {
fn from(value: &'static str) -> Self {
Self::At(SharedString::new_static(value))
}
}
impl From<String> for EntryTime {
fn from(value: String) -> Self {
Self::At(SharedString::from(value))
}
}
pub struct TimelineEntry {
id: SharedString,
time: EntryTime,
actor: Option<SharedString>,
description: SharedString,
tone: Tone,
detail: Option<AnyElement>,
}
impl std::fmt::Debug for TimelineEntry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TimelineEntry")
.field("id", &self.id)
.field("time", &self.time)
.field("actor", &self.actor)
.field("tone", &self.tone)
.field("has_detail", &self.detail.is_some())
.finish()
}
}
impl TimelineEntry {
pub fn new(id: impl Into<SharedString>, description: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
time: EntryTime::Unknown,
actor: None,
description: description.into(),
tone: Tone::Neutral,
detail: None,
}
}
pub fn time(mut self, time: impl Into<EntryTime>) -> Self {
self.time = time.into();
self
}
pub fn time_unknown(mut self) -> Self {
self.time = EntryTime::Unknown;
self
}
pub fn actor(mut self, actor: impl Into<SharedString>) -> Self {
self.actor = Some(actor.into());
self
}
pub fn tone(mut self, tone: Tone) -> Self {
self.tone = tone;
self
}
pub fn detail(mut self, detail: impl IntoElement) -> Self {
self.detail = Some(detail.into_any_element());
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
}
pub struct TimelineGroup {
id: SharedString,
label: SharedString,
entries: Vec<TimelineEntry>,
}
impl std::fmt::Debug for TimelineGroup {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TimelineGroup")
.field("id", &self.id)
.field("label", &self.label)
.field("entries", &self.entries.len())
.finish()
}
}
impl TimelineGroup {
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
entries: Vec::new(),
}
}
pub fn entry(mut self, entry: TimelineEntry) -> Self {
self.entries.push(entry);
self
}
pub fn entries(mut self, entries: impl IntoIterator<Item = TimelineEntry>) -> Self {
self.entries.extend(entries);
self
}
}
#[derive(IntoElement)]
pub struct Timeline {
ident: Ident,
groups: Vec<TimelineGroup>,
loose: Vec<TimelineEntry>,
}
impl std::fmt::Debug for Timeline {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Timeline")
.field("ident", &self.ident)
.field("groups", &self.groups.len())
.field("entries", &self.loose.len())
.finish()
}
}
impl Timeline {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
groups: Vec::new(),
loose: Vec::new(),
}
}
pub fn group(mut self, group: TimelineGroup) -> Self {
self.groups.push(group);
self
}
pub fn groups(mut self, groups: impl IntoIterator<Item = TimelineGroup>) -> Self {
self.groups.extend(groups);
self
}
pub fn entries(mut self, entries: impl IntoIterator<Item = TimelineEntry>) -> Self {
self.loose.extend(entries);
self
}
fn count(&self) -> usize {
self.loose.len()
+ self
.groups
.iter()
.map(|group| group.entries.len())
.sum::<usize>()
}
}
impl RenderOnce for Timeline {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let count = self.count();
let mut feed = div().column().w_full().gap_token(&theme, Space::Md);
for (index, entry) in self.loose.into_iter().enumerate() {
feed = feed.child(entry_element(&ident, &theme, entry, index + 1 < count, cx));
}
for group in self.groups {
let heading = div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.type_scale(&theme, TypeScale::Caption)
.text_color(theme.colors.text_faint)
.child(div().w(px(RAIL)).flex_none())
.child(group.label.clone())
.child(
div()
.flex_1()
.h(px(theme.borders.hairline))
.bg(theme.colors.hairline),
)
.semantic_in(
cx,
NodeSpec::new(ident.child(group.id.as_ref()).semantic_id(), Role::Heading)
.parent(ident.semantic_id())
.text(group.label.clone())
.value(group.entries.len().to_string()),
);
let last = group.entries.len().saturating_sub(1);
let mut section = div().column().w_full().gap_token(&theme, Space::Md);
for (index, entry) in group.entries.into_iter().enumerate() {
section = section.child(entry_element(&ident, &theme, entry, index < last, cx));
}
feed = feed.child(
div()
.column()
.w_full()
.gap_token(&theme, Space::Md)
.child(heading)
.child(section),
);
}
feed.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::List).value(count.to_string()),
)
}
}
fn entry_element(
timeline: &Ident,
theme: &Theme,
entry: TimelineEntry,
continues: bool,
cx: &mut App,
) -> AnyElement {
let ident = timeline.child(entry.id.as_ref());
let unknown = entry.time == EntryTime::Unknown;
let rail = div()
.w(px(RAIL))
.flex_none()
.column()
.items_center()
.child(div().mt(px(4.0)).child(StatusDot::new(entry.tone)))
.when(continues, |element| {
element.child(
div()
.mt(px(4.0))
.w(px(theme.borders.hairline))
.flex_1()
.min_h(px(theme.space(Space::Md)))
.bg(theme.colors.hairline),
)
});
let heading = div()
.row()
.flex_wrap()
.gap_token(theme, Space::Sm)
.type_scale(theme, TypeScale::Caption)
.child(
div()
.text_color(if unknown {
theme.colors.warning
} else {
theme.colors.text_muted
})
.child(entry.time.shown(cx)),
)
.children(entry.actor.clone().map(|actor| {
div()
.text_color(theme.colors.text_faint)
.child(actor.clone())
.semantic_in(
cx,
NodeSpec::new(ident.child("actor").semantic_id(), Role::Text)
.parent(ident.semantic_id())
.text(actor),
)
}));
div()
.row()
.items_start()
.w_full()
.gap_token(theme, Space::Sm)
.child(rail)
.child(
div()
.column()
.flex_1()
.min_w_0()
.gap(px(2.0))
.child(heading)
.child(
div()
.type_scale(theme, TypeScale::Label)
.text_color(theme.colors.text)
.child(entry.description.clone()),
)
.children(entry.detail.map(|detail| {
div()
.mt_token(theme, Space::Xs)
.type_scale(theme, TypeScale::Caption)
.text_color(theme.colors.text_muted)
.child(detail)
})),
)
.semantic_in(
cx,
NodeSpec::new(ident.semantic_id(), Role::Row)
.parent(timeline.semantic_id())
.text(entry.description.clone())
.value(match &entry.time {
EntryTime::At(time) => time.clone(),
EntryTime::Unknown => SharedString::new_static("time unknown"),
}),
)
.into_any_element()
}