kitmd 0.1.0

A terminal-based markdown and mermaid renderer/viewer using the Kitty graphics protocol
use super::*;

fn parse_journey_task_label(label: &str) -> (String, Vec<String>) {
    let mut lines = split_lines(label);
    if lines.is_empty() {
        return (String::new(), Vec::new());
    }
    let title = lines.remove(0).trim().to_string();
    let mut actors = Vec::new();
    for line in lines {
        for part in line.split(',') {
            let actor = part.trim();
            if !actor.is_empty() {
                actors.push(actor.to_string());
            }
        }
    }
    (title, actors)
}

fn journey_score_color(score: f32) -> String {
    let clamped = score.clamp(1.0, 5.0);
    let t = (clamped - 1.0) / 4.0;
    let start = (248.0, 113.0, 113.0);
    let end = (74.0, 222.0, 128.0);
    let r = (start.0 + (end.0 - start.0) * t).round() as i32;
    let g = (start.1 + (end.1 - start.1) * t).round() as i32;
    let b = (start.2 + (end.2 - start.2) * t).round() as i32;
    format!("#{:02X}{:02X}{:02X}", r, g, b)
}

pub(super) fn compute_journey_layout(
    graph: &Graph,
    theme: &Theme,
    config: &LayoutConfig,
) -> Layout {
    let mut section_defs: Vec<(String, Vec<String>)> = Vec::new();
    let mut assigned: HashSet<String> = HashSet::new();
    if graph.subgraphs.is_empty() {
        let mut ordered: Vec<String> = graph.nodes.keys().cloned().collect();
        ordered.sort_by_key(|id| graph.node_order.get(id).copied().unwrap_or(usize::MAX));
        section_defs.push((String::new(), ordered));
    } else {
        for sub in &graph.subgraphs {
            let mut nodes = Vec::new();
            for id in &sub.nodes {
                if graph.nodes.contains_key(id) {
                    nodes.push(id.clone());
                    assigned.insert(id.clone());
                }
            }
            section_defs.push((sub.label.clone(), nodes));
        }
        let mut extras: Vec<String> = graph
            .nodes
            .keys()
            .filter(|id| !assigned.contains(*id))
            .cloned()
            .collect();
        if !extras.is_empty() {
            extras.sort_by_key(|id| graph.node_order.get(id).copied().unwrap_or(usize::MAX));
            section_defs.push(("Other".to_string(), extras));
        }
    }

    struct TaskData {
        id: String,
        label: TextBlock,
        score: Option<f32>,
        actors: Vec<String>,
        section_idx: usize,
        order_idx: usize,
    }

    let mut tasks_data: Vec<TaskData> = Vec::new();
    let mut section_ranges: Vec<(usize, usize)> = Vec::new();
    let mut order_idx = 0usize;
    for (section_idx, (_label, nodes)) in section_defs.iter().enumerate() {
        let start_idx = order_idx;
        for node_id in nodes {
            if let Some(node) = graph.nodes.get(node_id) {
                let (title, actors) = parse_journey_task_label(&node.label);
                let title_text = if title.is_empty() {
                    node.label.clone()
                } else {
                    title
                };
                let label = measure_label(&title_text, theme, config);
                tasks_data.push(TaskData {
                    id: node_id.clone(),
                    label,
                    score: node.value,
                    actors,
                    section_idx,
                    order_idx,
                });
                order_idx += 1;
            }
        }
        let end_idx = order_idx.saturating_sub(1);
        section_ranges.push((start_idx, end_idx));
    }

    let mut actor_order: Vec<String> = Vec::new();
    let mut actor_set: HashSet<String> = HashSet::new();
    for task in &tasks_data {
        for actor in &task.actors {
            if actor_set.insert(actor.clone()) {
                actor_order.push(actor.clone());
            }
        }
    }

    let mut max_label_w = theme.font_size * 4.0;
    let mut max_label_h = theme.font_size * config.label_line_height;
    for task in &tasks_data {
        max_label_w = max_label_w.max(task.label.width);
        max_label_h = max_label_h.max(task.label.height);
    }

    let margin_x = theme.font_size * 2.0;
    let margin_y = theme.font_size * 2.0;
    let task_gap_x = theme.font_size * 1.6;
    let section_gap_y = theme.font_size * 1.8;
    let header_height = theme.font_size * 1.6;
    let card_gap_y = theme.font_size * 0.6;
    let score_radius = (theme.font_size * 0.55).max(6.0);
    let actor_radius = (theme.font_size * 0.35).max(4.0);
    let actor_gap = theme.font_size * 0.5;
    let task_pad_x = theme.font_size * 0.9;
    let task_pad_y = theme.font_size * 0.6;

    let task_width = (max_label_w + task_pad_x * 2.0).max(theme.font_size * 6.0);
    let task_height = (max_label_h + task_pad_y * 2.0).max(theme.font_size * 2.4);

    let title_block = graph
        .journey_title
        .as_ref()
        .map(|title| measure_label(title, theme, config));
    let mut cursor_y = margin_y;
    let title_y = if let Some(ref title) = title_block {
        let y = cursor_y + title.height / 2.0;
        cursor_y += title.height + theme.font_size * 0.6;
        y
    } else {
        0.0
    };

    let mut actors = Vec::new();
    let mut actor_label_y = 0.0;
    if !actor_order.is_empty() {
        let mut x = margin_x;
        let legend_y = cursor_y + actor_radius;
        actor_label_y = legend_y + theme.font_size * 0.35;
        for (idx, actor) in actor_order.iter().enumerate() {
            let label = measure_label(actor, theme, config);
            let color = theme.git_colors[idx % theme.git_colors.len()].clone();
            actors.push(JourneyActorLayout {
                name: actor.clone(),
                color: color.clone(),
                x: x + actor_radius,
                y: legend_y,
                radius: actor_radius,
            });
            x += actor_radius * 2.0 + actor_gap + label.width + theme.font_size * 0.8;
        }
        cursor_y += actor_radius * 2.0 + theme.font_size * 0.8;
    }

    let content_y = cursor_y;
    let has_actor_rows = tasks_data.iter().any(|task| !task.actors.is_empty());
    let actor_row_height = if has_actor_rows {
        actor_radius * 2.0
    } else {
        0.0
    };
    let actor_row_gap = if has_actor_rows {
        theme.font_size * 0.4
    } else {
        0.0
    };
    let row_height = header_height
        + score_radius * 2.0
        + card_gap_y
        + task_height
        + actor_row_gap
        + actor_row_height
        + theme.font_size * 0.6;

    let content_x = margin_x;
    let total_tasks = tasks_data.len();
    let task_area_width = if total_tasks > 0 {
        total_tasks as f32 * task_width + (total_tasks.saturating_sub(1)) as f32 * task_gap_x
    } else {
        0.0
    };

    let mut tasks = Vec::new();
    for task in &tasks_data {
        let row_top = content_y + task.section_idx as f32 * (row_height + section_gap_y);
        let card_y = row_top + header_height + score_radius * 2.0 + card_gap_y;
        let score_y = row_top + header_height + score_radius;
        let actor_y = if has_actor_rows {
            Some(card_y + task_height + actor_row_gap + actor_radius)
        } else {
            None
        };
        let x = content_x + task.order_idx as f32 * (task_width + task_gap_x);
        let score_color = task
            .score
            .map(journey_score_color)
            .unwrap_or_else(|| theme.secondary_color.clone());
        tasks.push(JourneyTaskLayout {
            id: task.id.clone(),
            label: task.label.clone(),
            x,
            y: card_y,
            width: task_width,
            height: task_height,
            score: task.score,
            score_color,
            score_y,
            actors: task.actors.clone(),
            actor_y,
            section_idx: task.section_idx,
        });
    }

    let mut sections = Vec::new();
    let section_pad_x = theme.font_size * 0.6;
    for (section_idx, (label, _nodes)) in section_defs.iter().enumerate() {
        let (start_idx, end_idx) = section_ranges.get(section_idx).copied().unwrap_or((0, 0));
        if start_idx > end_idx || total_tasks == 0 {
            continue;
        }
        let row_top = content_y + section_idx as f32 * (row_height + section_gap_y);
        let x = content_x + start_idx as f32 * (task_width + task_gap_x) - section_pad_x;
        let span = end_idx.saturating_sub(start_idx) + 1;
        let width = span as f32 * task_width
            + (span.saturating_sub(1)) as f32 * task_gap_x
            + section_pad_x * 2.0;
        let label_block = measure_label(label, theme, config);
        let color = theme.git_colors[section_idx % theme.git_colors.len()].clone();
        sections.push(JourneySectionLayout {
            label: label_block,
            x,
            y: row_top,
            width,
            height: header_height,
            color,
        });
    }

    let baseline = if total_tasks > 0 {
        let rows = section_defs.len();
        let total_rows_height = if rows > 0 {
            rows as f32 * row_height + (rows.saturating_sub(1)) as f32 * section_gap_y
        } else {
            0.0
        };
        let y = content_y + total_rows_height + theme.font_size * 0.5;
        Some((content_x, y, content_x + task_area_width))
    } else {
        None
    };

    let width = (content_x + task_area_width + margin_x).max(1.0);
    let height = baseline
        .map(|(_, y, _)| y + theme.font_size * 1.6)
        .unwrap_or(content_y + theme.font_size * 4.0)
        .max(1.0);

    let mut nodes = BTreeMap::new();
    nodes.insert(
        "__journey_metrics_content".to_string(),
        NodeLayout {
            id: "__journey_metrics_content".to_string(),
            x: margin_x,
            y: margin_y,
            width: (width - margin_x * 2.0).max(1.0),
            height: (height - margin_y * 2.0).max(1.0),
            label: TextBlock {
                lines: vec![String::new()],
                width: 0.0,
                height: 0.0,
            },
            shape: crate::mermaid_engine::ir::NodeShape::Rectangle,
            style: crate::mermaid_engine::ir::NodeStyle::default(),
            link: None,
            anchor_subgraph: None,
            hidden: false,
            icon: None,
        },
    );

    Layout {
        kind: graph.kind,
        nodes,
        edges: Vec::new(),
        subgraphs: Vec::new(),
        diagram: DiagramData::Journey(JourneyLayout {
            title: title_block,
            title_y,
            actors,
            actor_label_y,
            tasks,
            sections,
            baseline,
            score_radius,
            actor_radius,
            actor_gap,
            card_gap_y,
            width,
            height,
        }),
        width,
        height,
    }
}