gen 0.1.31

A sequence graph and version control system.
Documentation
use std::{collections::HashMap, io, time::Instant};

use crossterm::event::{self, Event, KeyCode};
use gen_diff::{
    graph::DiffGenGraph,
    operations::{BlockGroupDiff, OperationDiff},
};
use gen_models::db::GraphConnection;
use ratatui::{
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    widgets::{List, ListItem},
};

use crate::{
    config::get_theme_color,
    views::{
        diff_graph::{
            DiffGraphComponent, apply_diff_highlights, block_group_label,
            build_diff_graph_component, highlight_color_for_change_label,
            split_connected_components,
        },
        gen_graph_widget::{create_gen_graph_controller, create_gen_graph_widget},
        panels::{PanelFocus, PanelStyles, panel_block, render_status_bar},
        tui_runtime::TuiSession,
    },
};

struct DiffComponent {
    render: DiffGraphComponent,
    collection: String,
    sample: String,
    block_group: String,
    part_label: Option<String>,
    change_label: &'static str,
}

struct ListEntry {
    label: String,
    component_index: Option<usize>,
    db_path: String,
    is_header: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffPanel {
    List,
    Graph,
}

pub fn view_diff(
    conn: &GraphConnection,
    diffs: &HashMap<String, OperationDiff>,
) -> Result<(), io::Error> {
    let mut components: Vec<DiffComponent> = vec![];
    let mut components_by_db: HashMap<String, Vec<usize>> = HashMap::new();
    let mut db_order = diffs.keys().cloned().collect::<Vec<_>>();
    db_order.sort();

    for db_path in &db_order {
        if let Some(diff) = diffs.get(db_path)
            && let Some(db_diff) = diff.dbs.get(db_path)
        {
            for component in collect_components(&db_diff.added_block_groups, "Add") {
                let entry = components_by_db.entry(db_path.clone()).or_default();
                entry.push(components.len());
                components.push(component);
            }
            for component in collect_components(&db_diff.removed_block_groups, "Remove") {
                let entry = components_by_db.entry(db_path.clone()).or_default();
                entry.push(components.len());
                components.push(component);
            }
        }
    }

    if components.is_empty() {
        println!("No differences to display.");
        return Ok(());
    }

    let mut session = TuiSession::enter()?;
    let terminal = session.terminal_mut();

    let mut selected = 0usize;
    let mut expanded_db = db_order.first().cloned();
    let mut current_component = 0usize;

    let mut panel_focus = PanelFocus::new(DiffPanel::List);
    panel_focus.include_panel(DiffPanel::Graph);
    let panel_styles = PanelStyles::default();

    let mut graph_controller =
        create_gen_graph_controller(components[current_component].render.graph.clone());
    apply_diff_highlights(&mut graph_controller, &components[current_component].render);

    let mut last_frame_time = Instant::now();

    loop {
        let entries = build_entries(&db_order, &components, &components_by_db, &expanded_db);
        if entries.is_empty() {
            break;
        }
        if selected >= entries.len() {
            selected = 0;
        }
        let desired_component =
            resolve_selected_component(&entries, selected, &components_by_db, expanded_db.as_ref())
                .unwrap_or(0);
        if desired_component != current_component {
            current_component = desired_component;
            graph_controller =
                create_gen_graph_controller(components[current_component].render.graph.clone());
            apply_diff_highlights(&mut graph_controller, &components[current_component].render);
        }

        let now = Instant::now();
        let frame_delta = now.duration_since(last_frame_time);
        last_frame_time = now;

        terminal.draw(|f| {
            let outer = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Min(1), Constraint::Length(1)])
                .split(f.area());

            let main = Layout::default()
                .direction(Direction::Horizontal)
                .constraints([Constraint::Length(45), Constraint::Min(1)])
                .split(outer[0]);

            let list_items: Vec<ListItem> = entries
                .iter()
                .enumerate()
                .map(|(i, entry)| {
                    let style = if i == selected {
                        Style::default()
                            .fg(Color::Cyan)
                            .add_modifier(Modifier::BOLD)
                    } else {
                        Style::default()
                    };
                    ListItem::new(entry.label.clone()).style(style)
                })
                .collect();

            let list = List::new(list_items).block(panel_block(
                "Diff Parts",
                &panel_focus,
                DiffPanel::List,
                panel_styles,
            ));
            f.render_widget(list, main[0]);

            let graph_title = format!(
                "{} ({}/{})",
                components[current_component].render.title,
                current_component + 1,
                components.len()
            );
            let graph_block =
                panel_block(graph_title, &panel_focus, DiffPanel::Graph, panel_styles);
            let inner_canvas = graph_block.inner(main[1]);

            graph_controller.viewport_state.focus();
            graph_controller.viewport_state.viewport_bounds = inner_canvas;
            graph_controller.update_animations(frame_delta);

            f.render_widget(graph_block, main[1]);

            let canvas_style = Style::default().bg(get_theme_color("canvas").unwrap());
            let widget = create_gen_graph_widget(conn)
                .detail_level(graph_controller.get_detail_level())
                .style(canvas_style)
                .cursor();
            f.render_stateful_widget(widget, inner_canvas, &mut graph_controller);

            let panel_messages = if panel_focus.is_navigation() {
                "*tab* toggle focus | *enter* activate | *q* quit"
            } else if panel_focus.current() == DiffPanel::List {
                "*↑↓* select | *tab* toggle focus | *esc* leave panel | *q* quit"
            } else {
                "*←→↑↓* pan | *+/-* zoom | *tab* toggle focus | *esc* leave panel | *q* quit"
            };
            render_status_bar(f, outer[1], panel_messages);
        })?;

        if event::poll(std::time::Duration::from_millis(100))?
            && let Event::Key(key) = event::read()?
        {
            if panel_focus.is_navigation() {
                match key.code {
                    KeyCode::Tab | KeyCode::Left | KeyCode::Right => {
                        panel_focus.cycle_next();
                    }
                    KeyCode::Enter => {
                        panel_focus.activate();
                    }
                    KeyCode::Esc | KeyCode::Char('q') => break,
                    _ => {}
                }
            } else if key.code == KeyCode::Esc {
                panel_focus.deactivate();
            } else if key.code == KeyCode::Tab {
                panel_focus.cycle_next();
            } else if key.code == KeyCode::Char('q') {
                break;
            } else if panel_focus.current() == DiffPanel::List {
                match key.code {
                    KeyCode::Up => {
                        if selected > 0 {
                            selected -= 1;
                            if let Some(entry) = entries.get(selected) {
                                expanded_db = Some(entry.db_path.clone());
                            }
                        }
                    }
                    KeyCode::Down => {
                        if selected + 1 < entries.len() {
                            selected += 1;
                            if let Some(entry) = entries.get(selected) {
                                expanded_db = Some(entry.db_path.clone());
                            }
                        }
                    }
                    _ => {}
                }
            } else {
                let _ = graph_controller.handle_key_event(key);
            }
        }
    }

    Ok(())
}

fn collect_components(graphs: &[BlockGroupDiff], change_label: &'static str) -> Vec<DiffComponent> {
    let mut components = Vec::new();
    let highlight_color = highlight_color_for_change_label(change_label);
    for graph_diff in graphs {
        let parts = split_connected_components(&graph_diff.graph);
        let (collection, sample, block_group) = if let Some(bg) = &graph_diff.block_group {
            (
                bg.collection_name.clone(),
                bg.sample_name.clone(),
                bg.name.clone(),
            )
        } else {
            (
                String::from("Unknown"),
                String::from("Unknown"),
                String::from("Unknown"),
            )
        };
        if parts.len() <= 1 {
            components.push(build_component(
                &graph_diff.graph,
                change_label,
                highlight_color,
                &block_group_label(graph_diff),
                collection,
                sample,
                block_group,
                None,
            ));
        } else {
            let total = parts.len();
            for (idx, diff_graph) in parts.into_iter().enumerate() {
                components.push(build_component(
                    &diff_graph,
                    change_label,
                    highlight_color,
                    &format!(
                        "{} (part {}/{})",
                        block_group_label(graph_diff),
                        idx + 1,
                        total
                    ),
                    collection.clone(),
                    sample.clone(),
                    block_group.clone(),
                    Some(format!("part {}/{}", idx + 1, total)),
                ));
            }
        }
    }
    components
}

#[allow(clippy::too_many_arguments)]
fn build_component(
    diff_graph: &DiffGenGraph,
    change_label: &'static str,
    highlight_color: Color,
    title: &str,
    collection: String,
    sample: String,
    block_group: String,
    part_label: Option<String>,
) -> DiffComponent {
    DiffComponent {
        render: build_diff_graph_component(
            diff_graph,
            format!("{change_label} {title}"),
            highlight_color,
        ),
        collection,
        sample,
        block_group,
        part_label,
        change_label,
    }
}

fn build_entries(
    db_order: &[String],
    components: &[DiffComponent],
    components_by_db: &HashMap<String, Vec<usize>>,
    expanded_db: &Option<String>,
) -> Vec<ListEntry> {
    let mut entries = Vec::new();
    for db_path in db_order {
        entries.push(ListEntry {
            label: db_path.clone(),
            component_index: None,
            db_path: db_path.clone(),
            is_header: true,
        });
        if expanded_db.as_ref() == Some(db_path)
            && let Some(indices) = components_by_db.get(db_path)
        {
            for index in indices {
                let component = &components[*index];
                let part = component
                    .part_label
                    .as_ref()
                    .map(|p| format!(" | {p}"))
                    .unwrap_or_default();
                let label = format!(
                    "  {change} | {collection} | {sample} | {bg}{part}",
                    change = component.change_label,
                    collection = component.collection,
                    sample = component.sample,
                    bg = component.block_group,
                    part = part
                );
                entries.push(ListEntry {
                    label,
                    component_index: Some(*index),
                    db_path: db_path.clone(),
                    is_header: false,
                });
            }
        }
    }
    entries
}

fn resolve_selected_component(
    entries: &[ListEntry],
    selected: usize,
    components_by_db: &HashMap<String, Vec<usize>>,
    expanded_db: Option<&String>,
) -> Option<usize> {
    if let Some(entry) = entries.get(selected) {
        if let Some(index) = entry.component_index {
            return Some(index);
        }
        if entry.is_header
            && let Some(indices) = components_by_db.get(&entry.db_path)
        {
            return indices.first().copied();
        }
    }
    if let Some(db_path) = expanded_db
        && let Some(indices) = components_by_db.get(db_path)
    {
        return indices.first().copied();
    }
    None
}