use std::collections::HashMap;
use chrono::{Datelike, NaiveDate, TimeDelta};
use mermaid_rs_renderer::ir::{GanttStatus, GanttTask};
use unicode_width::UnicodeWidthStr;
use crate::tui::terminal_graph::{fit_label, GraphStyles, Oversize};
use super::MermaidArt;
const LABEL_CAP: usize = 20;
const LABEL_FLOOR: usize = 8;
const BAR_FLOOR: usize = 10;
const BAR_CAP: usize = 60;
const DEFAULT_DURATION: f32 = 3.0;
#[derive(Clone, Debug)]
pub(super) struct GanttModel {
pub(super) title: Option<String>,
pub(super) rows: Vec<GanttRow>,
pub(super) axis: GanttAxis,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum GanttAxis {
Calendar {
origin: NaiveDate,
start: f32,
end: f32,
},
Relative {
start: f32,
end: f32,
},
}
impl GanttAxis {
fn start(self) -> f32 {
match self {
Self::Calendar { start, .. } | Self::Relative { start, .. } => start,
}
}
fn end(self) -> f32 {
match self {
Self::Calendar { end, .. } | Self::Relative { end, .. } => end,
}
}
fn span(self) -> f32 {
(self.end() - self.start()).max(1.0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum GanttRow {
Section(String),
Task {
label: String,
start: f32,
duration: f32,
status: Option<GanttStatus>,
},
}
pub(super) fn from_ir(ir: &mermaid_rs_renderer::Graph) -> Option<GanttModel> {
if ir.gantt_tasks.is_empty() {
return None;
}
Some(schedule(&ir.gantt_tasks, ir.gantt_title.clone()))
}
pub(super) fn complexity(ir: &mermaid_rs_renderer::Graph) -> (usize, usize, usize, usize) {
(ir.gantt_tasks.len(), 0, ir.gantt_sections.len(), 0)
}
pub(super) fn schedule(tasks: &[GanttTask], title: Option<String>) -> GanttModel {
let mut parsed_ce: HashMap<String, i32> = HashMap::new();
let mut origin_ce: Option<i32> = None;
for task in tasks {
if let Some(start) = task.start.as_deref().and_then(parse_gantt_date) {
let start = start.num_days_from_ce();
parsed_ce.insert(task_key(&task.id), start);
origin_ce = Some(origin_ce.map_or(start, |value| value.min(start)));
}
}
let origin_date = origin_ce.and_then(NaiveDate::from_num_days_from_ce_opt);
let origin_ce = origin_ce.unwrap_or(0);
let parsed_starts: HashMap<String, f32> = parsed_ce
.into_iter()
.map(|(id, start)| (id, (start - origin_ce) as f32))
.collect();
let mut timing: HashMap<String, (f32, f32)> = HashMap::new();
let mut cursor = 0.0_f32;
let mut time_start = f32::MAX;
let mut time_end = f32::MIN;
let mut rows = Vec::new();
let mut current_section: Option<String> = None;
for task in tasks {
let duration = task
.duration
.as_deref()
.and_then(parse_gantt_duration)
.unwrap_or(DEFAULT_DURATION)
.max(0.0);
let start = parsed_starts
.get(&task_key(&task.id))
.copied()
.or_else(|| {
task.after
.as_deref()
.and_then(|after_id| timing.get(&task_key(after_id)).map(|(_, end)| *end))
})
.unwrap_or(cursor);
let end = start + duration;
timing.insert(task_key(&task.id), (start, end));
cursor = cursor.max(end + 0.5);
time_start = time_start.min(start);
time_end = time_end.max(end);
if task.section != current_section {
if let Some(name) = task.section.clone() {
rows.push(GanttRow::Section(name));
}
current_section = task.section.clone();
}
rows.push(GanttRow::Task {
label: task.label.clone(),
start,
duration,
status: task.status,
});
}
if !time_start.is_finite() || !time_end.is_finite() {
time_start = 0.0;
time_end = 1.0;
}
if (time_end - time_start).abs() < 0.01 {
time_end = time_start + 1.0;
}
let axis = if let Some(origin) = origin_date {
GanttAxis::Calendar {
origin,
start: time_start,
end: time_end,
}
} else {
GanttAxis::Relative {
start: time_start,
end: time_end,
}
};
GanttModel { title, rows, axis }
}
pub(super) fn layout_gantt(
model: &GanttModel,
styles: &GraphStyles,
max_width: Option<usize>,
) -> Result<MermaidArt, Oversize> {
let longest_label = model
.rows
.iter()
.filter_map(|row| match row {
GanttRow::Task { label, .. } => Some(label.width()),
GanttRow::Section(_) => None,
})
.max()
.unwrap_or(0);
let label_width = longest_label.clamp(LABEL_FLOOR, LABEL_CAP);
let bar_width = match max_width {
Some(max_width) => {
let available = max_width.saturating_sub(label_width.saturating_add(1));
if available < BAR_FLOOR {
return Err(Oversize::Width);
}
available.min(BAR_CAP)
}
None => BAR_CAP,
};
let total_width = label_width
.saturating_add(1)
.saturating_add(bar_width)
.max(1);
if max_width.is_some_and(|max_width| total_width > max_width) {
return Err(Oversize::Width);
}
let axis_span = model.axis.span();
let mut lines = Vec::new();
if let Some(title) = &model.title {
lines.push(fit_label(title, total_width));
}
for row in &model.rows {
match row {
GanttRow::Section(name) => lines.push(section_line(name, total_width)),
GanttRow::Task {
label,
start,
duration,
status,
} => {
let label = fit_label(label, label_width.max(1));
let bar = bar_line(
*start,
*duration,
model.axis.start(),
axis_span,
bar_width,
*status,
);
lines.push(format!("{} {}", pad_end(&label, label_width), bar));
}
}
}
lines.push(axis_footer(model, label_width, bar_width, total_width));
Ok(super::art_from_plain(lines, styles))
}
fn section_line(name: &str, width: usize) -> String {
let label = fit_label(name, width.saturating_sub(4).max(1));
let mut line = format!("── {label} ");
while line.width() < width {
line.push('─');
}
truncate_width(&line, width)
}
fn bar_line(
start: f32,
duration: f32,
axis_start: f32,
axis_span: f32,
bar_width: usize,
status: Option<GanttStatus>,
) -> String {
if bar_width == 0 {
return String::new();
}
let mut cells = vec![' '; bar_width];
let start_col = map_col(start, axis_start, axis_span, bar_width);
let end_col = map_col(start + duration, axis_start, axis_span, bar_width)
.max(start_col.saturating_add(1))
.min(bar_width);
if status == Some(GanttStatus::Milestone) {
cells[start_col.min(bar_width.saturating_sub(1))] = 'â—†';
return cells.into_iter().collect();
}
let fill = match status {
Some(GanttStatus::Done) => 'â–‘',
Some(GanttStatus::Active) => 'â–“',
Some(GanttStatus::Crit | GanttStatus::Milestone) | None => 'â–ˆ',
};
let mut fill_start = start_col;
if status == Some(GanttStatus::Crit) {
cells[start_col.min(bar_width.saturating_sub(1))] = '!';
fill_start = start_col.saturating_add(1);
}
for cell in cells.iter_mut().take(end_col).skip(fill_start) {
*cell = fill;
}
cells.into_iter().collect()
}
fn axis_footer(
model: &GanttModel,
label_width: usize,
bar_width: usize,
total_width: usize,
) -> String {
let start = axis_label(model.axis.start(), model.axis);
let end = axis_label(model.axis.end(), model.axis);
let mut bar = vec![' '; bar_width];
put_label(&mut bar, 0, &start);
let end_at = bar_width.saturating_sub(end.width());
if end_at > start.width() {
put_label(&mut bar, end_at, &end);
}
let line = format!(
"{} {}",
" ".repeat(label_width),
bar.into_iter().collect::<String>()
);
truncate_width(&line, total_width)
}
fn axis_label(value: f32, axis: GanttAxis) -> String {
match axis {
GanttAxis::Calendar { origin, .. } => origin
.checked_add_signed(TimeDelta::days(value.round() as i64))
.map(|date| date.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| format!("{}", value.round() as i32)),
GanttAxis::Relative { start, .. } => format!("{}d", (value - start).round() as i32),
}
}
fn put_label(cells: &mut [char], start: usize, label: &str) {
for (offset, character) in label.chars().enumerate() {
if let Some(cell) = cells.get_mut(start + offset) {
*cell = character;
}
}
}
fn map_col(value: f32, axis_start: f32, axis_span: f32, bar_width: usize) -> usize {
let t = ((value - axis_start) / axis_span).clamp(0.0, 1.0);
let col = (t * bar_width as f32).floor() as usize;
col.min(bar_width.saturating_sub(1))
}
fn pad_end(label: &str, width: usize) -> String {
let mut out = label.to_string();
while out.width() < width {
out.push(' ');
}
out
}
fn truncate_width(line: &str, width: usize) -> String {
if line.width() <= width {
return line.to_string();
}
fit_label(line, width)
}
fn task_key(id: &str) -> String {
id.to_ascii_lowercase()
}
pub(super) fn parse_gantt_duration(value: &str) -> Option<f32> {
let value = value.trim();
if value.is_empty() {
return None;
}
let split = value
.char_indices()
.find(|(_, character)| !character.is_ascii_digit() && *character != '.')
.map(|(index, _)| index)
.unwrap_or(value.len());
let number: f32 = value[..split].parse().ok()?;
let suffix = value[split..].trim();
let days = match suffix {
"" | "d" => 1.0,
"ms" => 1.0 / 86_400_000.0,
"s" => 1.0 / 86_400.0,
"m" => 1.0 / 1_440.0,
"h" => 1.0 / 24.0,
"w" => 7.0,
"M" => 30.0,
"y" => 365.0,
_ => return None,
};
Some(number * days)
}
pub(super) fn parse_gantt_date(value: &str) -> Option<NaiveDate> {
let value = value.trim();
if value.is_empty() {
return None;
}
let parts: Vec<&str> = value.split(['-', '/', '.']).collect();
if parts.len() != 3 {
return None;
}
let year: i32 = parts[0].parse().ok()?;
let month: u32 = parts[1].parse().ok()?;
let day: u32 = parts[2].parse().ok()?;
NaiveDate::from_ymd_opt(year, month, day)
}
#[cfg(test)]
#[path = "gantt_tests.rs"]
mod tests;