use uuid::Uuid;
#[derive(Debug, Clone)]
pub(crate) struct TimelineViewState {
pub book_id: Uuid,
pub scope_id: Uuid,
pub nav_history: Vec<Uuid>,
pub events: Vec<TimelineEvent>,
pub track_highlight: Option<String>,
pub ticks_per_cell: f64,
pub scroll_ticks: i64,
pub cursor_ticks: i64,
pub selected_event_id: Option<Uuid>,
pub collapsed_tracks: std::collections::HashSet<String>,
pub expanded_track: Option<String>,
pub focus_level: TimelineFocusLevel,
pub project_overlay: bool,
pub descent: Option<TimelineDescentState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TimelineFocusLevel {
Track,
Event,
}
#[derive(Debug, Clone)]
pub(crate) struct TimelineDescentState {
pub choices: Vec<TimelineDescentChoice>,
pub cursor: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct TimelineDescentChoice {
pub id: Uuid,
pub title: String,
pub event_count: usize,
}
#[derive(Debug, Clone)]
pub struct TimelineEvent {
pub id: Uuid,
pub title: String,
pub start_ticks: i64,
pub end_ticks: Option<i64>,
pub precision: crate::timeline::Precision,
pub track: Option<String>,
pub is_orphan: bool,
pub linked_paragraphs: Vec<Uuid>,
pub characters: Vec<Uuid>,
pub places: Vec<Uuid>,
pub book_prefix: String,
}
pub(crate) fn timeline_auto_fit(
events: &[TimelineEvent],
) -> (i64, i64, f64) {
let min_start = events
.iter()
.map(|e| e.start_ticks)
.min()
.unwrap_or(0);
let max_end = events
.iter()
.map(|e| e.end_ticks.unwrap_or(e.start_ticks).max(e.start_ticks))
.max()
.unwrap_or(min_start);
let span = (max_end - min_start).max(1);
let term_w = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let content_w = term_w.saturating_sub(16).max(40);
let target_w = (content_w as f64 * 0.8).max(20.0);
let ticks_per_cell = ((span as f64) / target_w).max(1.0);
let cursor_ticks = min_start + span / 2;
let pad = (content_w as f64 * 0.1 * ticks_per_cell).round() as i64;
let scroll_ticks = min_start.saturating_sub(pad);
(cursor_ticks, scroll_ticks, ticks_per_cell)
}
pub(super) fn timeline_step_event_cursor(
events: &[TimelineEvent],
cursor: i64,
direction: i64,
) -> Option<(Uuid, i64)> {
let mut by_start: Vec<(i64, Uuid)> = events
.iter()
.map(|e| (e.start_ticks, e.id))
.collect();
by_start.sort_by_key(|(t, _)| *t);
if by_start.is_empty() {
return None;
}
if direction > 0 {
by_start.into_iter().find(|(t, _)| *t > cursor).map(|(t, id)| (id, t))
} else {
by_start
.into_iter()
.rev()
.find(|(t, _)| *t < cursor)
.map(|(t, id)| (id, t))
}
}
pub(crate) fn cycle_track(current: Option<&str>, tracks: &[String]) -> Option<String> {
if tracks.is_empty() {
return None;
}
match current {
None => Some(tracks[0].clone()),
Some(cur) => {
let idx = tracks.iter().position(|t| t == cur);
match idx {
Some(i) if i + 1 < tracks.len() => Some(tracks[i + 1].clone()),
_ => None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::cycle_track;
#[test]
fn cycle_through_tracks_then_back_to_none() {
let tracks = vec!["flashback".to_string(), "main".to_string()];
assert_eq!(cycle_track(None, &tracks).as_deref(), Some("flashback"));
assert_eq!(
cycle_track(Some("flashback"), &tracks).as_deref(),
Some("main")
);
assert_eq!(cycle_track(Some("main"), &tracks), None);
}
#[test]
fn cycle_empty_tracks_returns_none() {
assert_eq!(cycle_track(None, &[]), None);
assert_eq!(cycle_track(Some("anything"), &[]), None);
}
}