use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::graph_renderer::{ACCENT_BLUE, ACCENT_LAVENDER, BG_SURFACE, FG_OVERLAY, FG_TEXT};
pub struct TimelineState {
pub commits: Vec<(String, String, i64)>,
pub current_index: usize,
}
impl TimelineState {
pub fn new(commits: Vec<(String, String, i64)>) -> Self {
Self {
current_index: 0,
commits,
}
}
pub fn next(&mut self) {
if self.current_index + 1 < self.commits.len() {
self.current_index += 1;
}
}
pub fn prev(&mut self) {
if self.current_index > 0 {
self.current_index -= 1;
}
}
pub fn jump_by(&mut self, n: isize) {
if self.commits.is_empty() {
return;
}
let max = self.commits.len() - 1;
let new_idx = (self.current_index as isize + n).clamp(0, max as isize) as usize;
self.current_index = new_idx;
}
pub fn jump_to_start(&mut self) {
self.current_index = 0;
}
pub fn jump_to_end(&mut self) {
if !self.commits.is_empty() {
self.current_index = self.commits.len() - 1;
}
}
pub fn jump_to(&mut self, index: usize) {
if self.commits.is_empty() {
return;
}
self.current_index = index.min(self.commits.len() - 1);
}
pub fn current_commit_hash(&self) -> Option<&str> {
self.commits
.get(self.current_index)
.map(|(h, _, _)| h.as_str())
}
pub fn current_commit_message(&self) -> Option<&str> {
self.commits
.get(self.current_index)
.map(|(_, m, _)| m.as_str())
}
pub fn current_commit_timestamp(&self) -> i64 {
self.commits
.get(self.current_index)
.map(|(_, _, ts)| *ts)
.unwrap_or(0)
}
pub fn len(&self) -> usize {
self.commits.len()
}
pub fn is_empty(&self) -> bool {
self.commits.is_empty()
}
}
pub fn render_timeline(frame: &mut Frame, area: Rect, state: &TimelineState, focused: bool) {
let border_color = if focused { ACCENT_LAVENDER } else { FG_OVERLAY };
let title_style = if focused {
Style::default()
.fg(ACCENT_LAVENDER)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(FG_OVERLAY)
};
let block = Block::default()
.title(Span::styled(
" Timeline (j/k H/L ±10 Home/End +/-:speed click:seek) ",
title_style,
))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.style(Style::default().bg(BG_SURFACE));
let inner = block.inner(area);
frame.render_widget(block, area);
if state.is_empty() {
let empty = Paragraph::new(" No history yet. Run `morpharch scan .` first.")
.style(Style::default().fg(FG_OVERLAY));
frame.render_widget(empty, inner);
return;
}
let total = state.len();
let bar_width = inner.width.saturating_sub(2) as usize;
let slider_chars: String = if bar_width > 0 && total > 0 {
let pos = if total <= 1 {
0
} else {
(state.current_index * (bar_width.saturating_sub(1))) / (total.saturating_sub(1))
};
(0..bar_width)
.map(|i| if i == pos { '█' } else { '─' })
.collect()
} else {
String::new()
};
let hash = state.current_commit_hash().unwrap_or("?");
let short_hash = if hash.len() >= 7 { &hash[..7] } else { hash };
let message = state.current_commit_message().unwrap_or("");
let truncated_msg = if message.chars().count() > 50 {
format!("{}…", message.chars().take(49).collect::<String>())
} else {
message.to_string()
};
let timestamp = state.current_commit_timestamp();
let date_str = if timestamp > 0 {
chrono::DateTime::from_timestamp(timestamp, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "-----".to_string())
} else {
"-----".to_string()
};
let lines = vec![
Line::from(vec![
Span::styled(" ", Style::default().fg(FG_TEXT)),
Span::styled(slider_chars, Style::default().fg(ACCENT_BLUE)),
]),
Line::from(vec![
Span::styled(
format!(" [{}/{}] ", state.current_index + 1, total),
Style::default()
.fg(ACCENT_LAVENDER)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{date_str} "), Style::default().fg(FG_OVERLAY)),
Span::styled(format!("{short_hash} "), Style::default().fg(ACCENT_BLUE)),
Span::styled(truncated_msg, Style::default().fg(FG_TEXT)),
]),
];
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timeline_navigation() {
let commits = vec![
("aaa".to_string(), "First".to_string(), 3),
("bbb".to_string(), "Second".to_string(), 2),
("ccc".to_string(), "Third".to_string(), 1),
];
let mut state = TimelineState::new(commits);
assert_eq!(state.current_index, 0);
assert_eq!(state.current_commit_hash(), Some("aaa"));
state.next();
assert_eq!(state.current_index, 1);
assert_eq!(state.current_commit_hash(), Some("bbb"));
state.next();
assert_eq!(state.current_index, 2);
assert_eq!(state.current_commit_hash(), Some("ccc"));
state.next();
assert_eq!(state.current_index, 2);
state.prev();
assert_eq!(state.current_index, 1);
state.prev();
state.prev();
assert_eq!(state.current_index, 0);
}
#[test]
fn test_timeline_empty() {
let state = TimelineState::new(vec![]);
assert!(state.is_empty());
assert_eq!(state.current_commit_hash(), None);
assert_eq!(state.len(), 0);
}
#[test]
fn test_timeline_jump_by() {
let commits: Vec<(String, String, i64)> = (0..20)
.map(|i| (format!("hash{i}"), format!("Msg {i}"), i as i64))
.collect();
let mut state = TimelineState::new(commits);
assert_eq!(state.current_index, 0);
state.jump_by(10);
assert_eq!(state.current_index, 10);
state.jump_by(10);
assert_eq!(state.current_index, 19);
state.jump_by(-5);
assert_eq!(state.current_index, 14);
state.jump_by(-100);
assert_eq!(state.current_index, 0);
}
#[test]
fn test_timeline_jump_to_start_end() {
let commits: Vec<(String, String, i64)> = (0..50)
.map(|i| (format!("hash{i}"), format!("Msg {i}"), i as i64))
.collect();
let mut state = TimelineState::new(commits);
state.jump_to_end();
assert_eq!(state.current_index, 49);
state.jump_to_start();
assert_eq!(state.current_index, 0);
}
#[test]
fn test_timeline_jump_to() {
let commits: Vec<(String, String, i64)> = (0..10)
.map(|i| (format!("hash{i}"), format!("Msg {i}"), i as i64))
.collect();
let mut state = TimelineState::new(commits);
state.jump_to(5);
assert_eq!(state.current_index, 5);
state.jump_to(100);
assert_eq!(state.current_index, 9);
}
}