use uuid::Uuid;
use super::app::TimelineEvent;
#[derive(Debug, Clone)]
pub(crate) struct TrackRow {
pub label: String,
pub cells: Vec<Option<TrackCell>>,
pub is_orphan_row: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct TrackCell {
pub glyph: char,
pub event_id: Uuid,
pub is_endpoint: bool,
pub is_orphan: bool,
}
pub(crate) fn layout_swim_lanes(
events: &[TimelineEvent],
scroll_ticks: i64,
ticks_per_cell: f64,
width: usize,
default_track_label: &str,
show_orphans: bool,
) -> Vec<TrackRow> {
if width == 0 || ticks_per_cell <= 0.0 {
return Vec::new();
}
let mut tracks: Vec<String> = events
.iter()
.filter(|e| !e.is_orphan)
.map(|e| {
e.track
.clone()
.unwrap_or_else(|| default_track_label.to_owned())
})
.collect();
tracks.sort();
tracks.dedup();
if let Some(idx) = tracks.iter().position(|t| t == default_track_label) {
tracks.swap(0, idx);
}
let mut rows: Vec<TrackRow> = tracks
.into_iter()
.map(|label| TrackRow {
label,
cells: vec![None; width],
is_orphan_row: false,
})
.collect();
if show_orphans && events.iter().any(|e| e.is_orphan) {
rows.push(TrackRow {
label: "orphan".into(),
cells: vec![None; width],
is_orphan_row: true,
});
}
for ev in events {
let row_idx = if ev.is_orphan {
rows.iter()
.position(|r| r.is_orphan_row)
} else {
let want = ev
.track
.clone()
.unwrap_or_else(|| default_track_label.to_owned());
rows.iter().position(|r| !r.is_orphan_row && r.label == want)
};
let Some(row_idx) = row_idx else { continue };
let start_col = tick_to_col(ev.start_ticks, scroll_ticks, ticks_per_cell);
let end_col_excl = match ev.end_ticks {
Some(end_t) => {
let c = tick_to_col(end_t, scroll_ticks, ticks_per_cell);
c.max(start_col + 1)
}
None => start_col + 1,
};
if end_col_excl <= 0 || start_col >= width as isize {
continue;
}
let s = start_col.max(0) as usize;
let e = (end_col_excl.min(width as isize)) as usize;
for col in s..e {
let glyph = if ev.end_ticks.is_none() {
if ev.is_orphan { '◌' } else { '●' }
} else {
if col == s {
'├'
} else if col == e - 1 {
'┤'
} else {
'─'
}
};
rows[row_idx].cells[col] = Some(TrackCell {
glyph,
event_id: ev.id,
is_endpoint: col == s || col == e - 1,
is_orphan: ev.is_orphan,
});
}
}
rows
}
fn tick_to_col(ticks: i64, scroll_ticks: i64, ticks_per_cell: f64) -> isize {
let delta = (ticks - scroll_ticks) as f64;
(delta / ticks_per_cell).round() as isize
}
pub(crate) fn col_to_tick(col: usize, scroll_ticks: i64, ticks_per_cell: f64) -> i64 {
scroll_ticks + (col as f64 * ticks_per_cell).round() as i64
}
pub(crate) fn time_axis_labels(
scroll_ticks: i64,
ticks_per_cell: f64,
width: usize,
) -> Vec<(usize, i64)> {
if width == 0 || ticks_per_cell <= 0.0 {
return Vec::new();
}
let target_spacing = 14usize.min(width);
let n_labels = (width / target_spacing).max(1);
let mut out = Vec::with_capacity(n_labels);
for i in 0..=n_labels {
let col = (i * width / n_labels).min(width.saturating_sub(1));
let tick = col_to_tick(col, scroll_ticks, ticks_per_cell);
out.push((col, tick));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::Precision;
fn ev(
title: &str,
track: Option<&str>,
start: i64,
end: Option<i64>,
) -> TimelineEvent {
TimelineEvent {
id: Uuid::nil(),
title: title.into(),
start_ticks: start,
end_ticks: end,
precision: Precision::Day,
track: track.map(str::to_owned),
is_orphan: false,
linked_paragraphs: Vec::new(),
characters: Vec::new(),
places: Vec::new(),
book_prefix: String::new(),
}
}
#[test]
fn instant_event_lands_one_cell() {
let events = vec![ev("A", Some("main"), 5, None)];
let rows = layout_swim_lanes(&events, 0, 1.0, 20, "main", true);
assert_eq!(rows.len(), 1);
let row = &rows[0];
assert_eq!(row.label, "main");
assert!(row.cells[5].is_some());
assert_eq!(row.cells[5].as_ref().unwrap().glyph, '●');
assert!(row.cells[6].is_none());
}
#[test]
fn bar_event_spans_cells_with_endpoints() {
let events = vec![ev("A", Some("main"), 5, Some(10))];
let rows = layout_swim_lanes(&events, 0, 1.0, 20, "main", true);
let row = &rows[0];
assert_eq!(row.cells[5].as_ref().unwrap().glyph, '├');
assert_eq!(row.cells[9].as_ref().unwrap().glyph, '┤');
for c in &row.cells[6..9] {
assert_eq!(c.as_ref().unwrap().glyph, '─');
}
}
#[test]
fn orphan_collected_into_synthetic_row() {
let mut e = ev("A", None, 5, None);
e.is_orphan = true;
let rows = layout_swim_lanes(&[e], 0, 1.0, 10, "main", true);
assert_eq!(rows.len(), 1);
assert!(rows[0].is_orphan_row);
assert_eq!(rows[0].label, "orphan");
assert_eq!(rows[0].cells[5].as_ref().unwrap().glyph, '◌');
}
#[test]
fn orphan_hidden_when_show_orphans_false() {
let mut e = ev("A", None, 5, None);
e.is_orphan = true;
let rows = layout_swim_lanes(&[e], 0, 1.0, 10, "main", false);
assert!(rows.is_empty());
}
#[test]
fn default_track_first_then_alpha() {
let events = vec![
ev("A", Some("zeta"), 0, None),
ev("B", Some("main"), 1, None),
ev("C", Some("alpha"), 2, None),
];
let rows = layout_swim_lanes(&events, 0, 1.0, 10, "main", true);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].label, "main");
assert_eq!(rows[1].label, "alpha");
assert_eq!(rows[2].label, "zeta");
}
#[test]
fn event_left_of_viewport_clipped() {
let events = vec![ev("A", Some("main"), -5, Some(3))];
let rows = layout_swim_lanes(&events, 0, 1.0, 10, "main", true);
let row = &rows[0];
assert!(row.cells[0].is_some());
assert!(row.cells[2].is_some());
assert!(row.cells[3].is_none());
}
#[test]
fn zoom_changes_cells_per_event() {
let events = vec![
ev("A", Some("main"), 0, None),
ev("B", Some("main"), 10, None),
];
let rows = layout_swim_lanes(&events, 0, 2.0, 20, "main", true);
let row = &rows[0];
assert!(row.cells[0].is_some());
assert!(row.cells[5].is_some());
}
#[test]
fn axis_labels_evenly_spaced() {
let labels = time_axis_labels(0, 1.0, 60);
assert!(labels.len() >= 4 && labels.len() <= 6);
assert_eq!(labels[0].0, 0);
assert!(labels.last().unwrap().0 >= 50);
}
#[test]
fn col_to_tick_roundtrips_within_cell() {
let tick = 42;
let col = ((tick - 0) as f64 / 1.0).round() as usize;
assert_eq!(col_to_tick(col, 0, 1.0), tick);
}
}