use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::utils::{char_width, display_width, truncate_to_width};
use crate::widget::theme::{DARK_GRAY, LIGHT_GRAY, MUTED_TEXT};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EventType {
#[default]
Info,
Success,
Warning,
Error,
Custom(char),
}
impl EventType {
pub fn icon(&self) -> char {
match self {
EventType::Info => '●',
EventType::Success => '✓',
EventType::Warning => '⚠',
EventType::Error => '✗',
EventType::Custom(c) => *c,
}
}
pub fn color(&self) -> Color {
match self {
EventType::Info => Color::CYAN,
EventType::Success => Color::GREEN,
EventType::Warning => Color::YELLOW,
EventType::Error => Color::RED,
EventType::Custom(_) => Color::WHITE,
}
}
}
#[derive(Clone, Debug)]
pub struct TimelineEvent {
pub title: String,
pub description: Option<String>,
pub timestamp: Option<String>,
pub event_type: EventType,
pub color: Option<Color>,
pub metadata: Vec<(String, String)>,
}
impl TimelineEvent {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
description: None,
timestamp: None,
event_type: EventType::Info,
color: None,
metadata: Vec::new(),
}
}
pub fn description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
self.timestamp = Some(ts.into());
self
}
pub fn event_type(mut self, t: EventType) -> Self {
self.event_type = t;
self
}
pub fn success(mut self) -> Self {
self.event_type = EventType::Success;
self
}
pub fn warning(mut self) -> Self {
self.event_type = EventType::Warning;
self
}
pub fn error(mut self) -> Self {
self.event_type = EventType::Error;
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn meta(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.push((key.into(), value.into()));
self
}
pub fn display_color(&self) -> Color {
self.color.unwrap_or_else(|| self.event_type.color())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TimelineOrientation {
#[default]
Vertical,
Horizontal,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TimelineStyle {
#[default]
Line,
Boxed,
Minimal,
Alternating,
}
pub struct Timeline {
events: Vec<TimelineEvent>,
orientation: TimelineOrientation,
style: TimelineStyle,
selected: Option<usize>,
scroll: usize,
show_timestamps: bool,
show_descriptions: bool,
line_color: Color,
timestamp_color: Color,
title_color: Color,
desc_color: Color,
props: WidgetProps,
}
impl Timeline {
pub fn new() -> Self {
Self {
events: Vec::new(),
orientation: TimelineOrientation::Vertical,
style: TimelineStyle::Line,
selected: None,
scroll: 0,
show_timestamps: true,
show_descriptions: true,
line_color: DARK_GRAY,
timestamp_color: LIGHT_GRAY,
title_color: Color::WHITE,
desc_color: MUTED_TEXT,
props: WidgetProps::new(),
}
}
pub fn event(mut self, event: TimelineEvent) -> Self {
self.events.push(event);
self
}
pub fn events(mut self, events: Vec<TimelineEvent>) -> Self {
self.events.extend(events);
self
}
pub fn orientation(mut self, orientation: TimelineOrientation) -> Self {
self.orientation = orientation;
self
}
pub fn vertical(mut self) -> Self {
self.orientation = TimelineOrientation::Vertical;
self
}
pub fn horizontal(mut self) -> Self {
self.orientation = TimelineOrientation::Horizontal;
self
}
pub fn style(mut self, style: TimelineStyle) -> Self {
self.style = style;
self
}
pub fn timestamps(mut self, show: bool) -> Self {
self.show_timestamps = show;
self
}
pub fn descriptions(mut self, show: bool) -> Self {
self.show_descriptions = show;
self
}
pub fn line_color(mut self, color: Color) -> Self {
self.line_color = color;
self
}
pub fn select(&mut self, index: Option<usize>) {
self.selected = index;
}
pub fn select_next(&mut self) {
match self.selected {
Some(i) if i < self.events.len() - 1 => self.selected = Some(i + 1),
None if !self.events.is_empty() => self.selected = Some(0),
_ => {}
}
}
pub fn select_prev(&mut self) {
match self.selected {
Some(i) if i > 0 => self.selected = Some(i - 1),
_ => {}
}
}
pub fn selected_event(&self) -> Option<&TimelineEvent> {
self.selected.and_then(|i| self.events.get(i))
}
pub fn clear(&mut self) {
self.events.clear();
self.selected = None;
self.scroll = 0;
}
pub fn push(&mut self, event: TimelineEvent) {
self.events.push(event);
}
pub fn len(&self) -> usize {
self.events.len()
}
pub fn is_empty(&self) -> bool {
self.events.is_empty()
}
}
impl Default for Timeline {
fn default() -> Self {
Self::new()
}
}
impl View for Timeline {
crate::impl_view_meta!("Timeline");
fn render(&self, ctx: &mut RenderContext) {
match self.orientation {
TimelineOrientation::Vertical => self.render_vertical(ctx),
TimelineOrientation::Horizontal => self.render_horizontal(ctx),
}
}
}
impl_styled_view!(Timeline);
impl_props_builders!(Timeline);
impl Timeline {
fn render_vertical(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if self.events.is_empty() || area.height < 2 {
return;
}
let timestamp_width = if self.show_timestamps { 12 } else { 0 };
let icon_x = timestamp_width;
let content_x = icon_x + 3;
let content_width = area.width.saturating_sub(timestamp_width + 3);
let mut y = 0u16;
for (i, event) in self.events.iter().enumerate().skip(self.scroll) {
if y >= area.height {
break;
}
let is_selected = self.selected == Some(i);
let color = event.display_color();
if self.show_timestamps {
if let Some(ref ts) = event.timestamp {
let truncated = truncate_to_width(ts, timestamp_width as usize - 1);
let mut dx: u16 = 0;
for ch in truncated.chars() {
let cw = char_width(ch) as u16;
if dx + cw > timestamp_width - 1 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.timestamp_color);
ctx.set(dx, y, cell);
dx += cw;
}
}
}
let icon = event.event_type.icon();
let mut icon_cell = Cell::new(icon);
icon_cell.fg = Some(color);
if is_selected {
icon_cell.modifier |= Modifier::BOLD;
}
ctx.set(icon_x, y, icon_cell);
if i < self.events.len() - 1 && self.style != TimelineStyle::Minimal {
let line_char = match self.style {
TimelineStyle::Line => '│',
TimelineStyle::Boxed => '│',
TimelineStyle::Alternating => '│',
TimelineStyle::Minimal => ' ',
};
let line_y = y + 1;
if line_y < area.height {
let mut line_cell = Cell::new(line_char);
line_cell.fg = Some(self.line_color);
ctx.set(icon_x, line_y, line_cell);
}
}
let connector = match self.style {
TimelineStyle::Line | TimelineStyle::Alternating => '─',
TimelineStyle::Boxed => '─',
TimelineStyle::Minimal => ' ',
};
if self.style != TimelineStyle::Minimal {
let mut conn_cell = Cell::new(connector);
conn_cell.fg = Some(self.line_color);
ctx.set(icon_x + 1, y, conn_cell);
}
let title_fg = if is_selected { color } else { self.title_color };
let title_truncated = truncate_to_width(&event.title, content_width as usize);
let mut dx: u16 = 0;
for ch in title_truncated.chars() {
let cw = char_width(ch) as u16;
if dx + cw > content_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(title_fg);
if is_selected {
cell.modifier |= Modifier::BOLD;
}
ctx.set(content_x + dx, y, cell);
dx += cw;
}
y += 1;
if self.show_descriptions {
if let Some(ref desc) = event.description {
if y < area.height {
if i < self.events.len() - 1 && self.style != TimelineStyle::Minimal {
let mut line_cell = Cell::new('│');
line_cell.fg = Some(self.line_color);
ctx.set(icon_x, y, line_cell);
}
let desc_truncated = truncate_to_width(desc, content_width as usize);
let mut dx: u16 = 0;
for ch in desc_truncated.chars() {
let cw = char_width(ch) as u16;
if dx + cw > content_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.desc_color);
ctx.set(content_x + dx, y, cell);
dx += cw;
}
y += 1;
}
}
}
if y < area.height && i < self.events.len() - 1 {
if self.style != TimelineStyle::Minimal {
let mut line_cell = Cell::new('│');
line_cell.fg = Some(self.line_color);
ctx.set(icon_x, y, line_cell);
}
y += 1;
}
}
}
fn render_horizontal(&self, ctx: &mut RenderContext) {
let area = ctx.area;
if self.events.is_empty() || area.width < 10 {
return;
}
let event_width = 15u16;
let line_y = 1u16;
for x in 0..area.width {
let mut cell = Cell::new('─');
cell.fg = Some(self.line_color);
ctx.set(x, line_y, cell);
}
let mut x = 0u16;
for (i, event) in self.events.iter().enumerate() {
if x >= area.width {
break;
}
let is_selected = self.selected == Some(i);
let color = event.display_color();
let icon = event.event_type.icon();
let mut icon_cell = Cell::new(icon);
icon_cell.fg = Some(color);
if is_selected {
icon_cell.modifier |= Modifier::BOLD;
}
ctx.set(x + event_width / 2, line_y, icon_cell);
let title = truncate_to_width(&event.title, event_width as usize - 1);
let title_x = x + (event_width.saturating_sub(display_width(title) as u16)) / 2;
let mut dx: u16 = 0;
for ch in title.chars() {
let cw = char_width(ch) as u16;
if dx + cw > event_width - 1 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(if is_selected { color } else { self.title_color });
ctx.set(title_x + dx, 0, cell);
dx += cw;
}
if self.show_timestamps {
if let Some(ref ts) = event.timestamp {
let ts_str = truncate_to_width(ts, event_width as usize - 1);
let ts_x = x + (event_width.saturating_sub(display_width(ts_str) as u16)) / 2;
let mut dx: u16 = 0;
for ch in ts_str.chars() {
let cw = char_width(ch) as u16;
if dx + cw > event_width - 1 {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.timestamp_color);
ctx.set(ts_x + dx, line_y + 1, cell);
dx += cw;
}
}
}
x += event_width;
}
}
}
pub fn timeline() -> Timeline {
Timeline::new()
}
pub fn timeline_event(title: impl Into<String>) -> TimelineEvent {
TimelineEvent::new(title)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layout::Rect;
use crate::render::Buffer;
#[test]
fn test_timeline_render() {
let mut buffer = Buffer::new(60, 20);
let area = Rect::new(0, 0, 60, 20);
let mut ctx = RenderContext::new(&mut buffer, area);
let tl = Timeline::new()
.event(TimelineEvent::new("Event 1").timestamp("10:00"))
.event(TimelineEvent::new("Event 2").timestamp("11:00"));
tl.render(&mut ctx);
}
#[test]
fn test_render_horizontal() {
let mut buffer = Buffer::new(60, 10);
let area = Rect::new(0, 0, 60, 10);
let mut ctx = RenderContext::new(&mut buffer, area);
let tl = Timeline::new()
.horizontal()
.event(TimelineEvent::new("Event 1").timestamp("10:00"))
.event(TimelineEvent::new("Event 2").timestamp("11:00"));
tl.render(&mut ctx); }
#[test]
fn test_render_empty() {
let mut buffer = Buffer::new(60, 10);
let area = Rect::new(0, 0, 60, 10);
let mut ctx = RenderContext::new(&mut buffer, area);
let tl = Timeline::new();
tl.render(&mut ctx); }
#[test]
fn test_render_with_descriptions() {
let mut buffer = Buffer::new(60, 10);
let area = Rect::new(0, 0, 60, 10);
let mut ctx = RenderContext::new(&mut buffer, area);
let tl = Timeline::new()
.descriptions(true)
.event(TimelineEvent::new("Event").description("Details here"));
tl.render(&mut ctx);
}
#[test]
fn test_clear() {
let mut tl = Timeline::new()
.event(TimelineEvent::new("A"))
.event(TimelineEvent::new("B"))
.event(TimelineEvent::new("C"));
tl.select_next();
tl.clear();
assert!(tl.is_empty());
assert_eq!(tl.selected, None);
assert_eq!(tl.scroll, 0);
}
}
#[test]
fn test_timeline_render_private() {
let _t = Timeline::new().event(TimelineEvent::new("Test"));
}