cargo-perspective 0.1.0

A utility that graphically shows the crate dependency distribution within a project.
Documentation
//! Dioxus UI for the TreeMap visualization.

use crate::treemap::layout_hierarchical;
use crate::types::{ProjectAnalysis, TreeMapNode, TreeMapRect};
use dioxus::prelude::*;
use std::sync::{Arc, Mutex};

const WINDOW_WIDTH: f64 = 1200.0;
const WINDOW_HEIGHT: f64 = 800.0;
const PADDING: f64 = 2.0;

/// Global state for the analysis data
static ANALYSIS_DATA: Mutex<Option<Arc<ProjectAnalysis>>> = Mutex::new(None);

/// Main application component.
#[component]
pub fn App() -> Element {
    // Get analysis from global state
    let analysis = {
        let data = ANALYSIS_DATA.lock().unwrap();
        data.clone().expect("Analysis data not initialized")
    };
    let mut selected_node = use_signal(|| None::<String>);
    let mut zoom_stack = use_signal(|| Vec::<String>::new());

    // Convert analysis to TreeMap nodes
    let mut tree_nodes = TreeMapNode::from_analysis(&analysis);

    // Apply zoom if needed
    let current_zoom = zoom_stack.read().last().cloned();
    if let Some(ref crate_name) = current_zoom {
        // Find the zoomed crate
        if let Some(node) = tree_nodes.iter_mut().find(|n| &n.label == crate_name) {
            // Show only this crate's methods
            tree_nodes = vec![node.clone()];
        }
    }

    // Layout the TreeMap
    let bounds = TreeMapRect::new(0.0, 0.0, WINDOW_WIDTH, WINDOW_HEIGHT);
    layout_hierarchical(&mut tree_nodes, bounds, PADDING);

    let on_select = move |label: String| {
        selected_node.set(Some(label));
    };

    let on_zoom = move |label: String| {
        zoom_stack.write().push(label);
    };

    let on_zoom_out = move |_| {
        zoom_stack.write().pop();
    };

    rsx! {
        style { {include_str!("ui_styles.css")} }
        div { class: "container",
            div { class: "header",
                h1 { "Cargo Perspective - {analysis.root_crate}" }
                div { class: "stats",
                    span { "Total calls: {analysis.total_calls}" }
                    span { " | Crates: {analysis.crates.len()}" }
                }
                if current_zoom.is_some() {
                    button {
                        class: "zoom-out-btn",
                        onclick: on_zoom_out,
                        "← Back to overview"
                    }
                }
            }

            svg {
                class: "treemap",
                width: "{WINDOW_WIDTH}",
                height: "{WINDOW_HEIGHT}",
                view_box: "0 0 {WINDOW_WIDTH} {WINDOW_HEIGHT}",

                // Render TreeMap
                for node in &tree_nodes {
                    CrateNode {
                        node: node.clone(),
                        selected: selected_node.read().clone(),
                        on_select: on_select,
                        on_zoom: on_zoom
                    }
                }
            }

            // Info panel
            if let Some(ref label) = *selected_node.read() {
                {render_info_panel(label, &analysis)}
            }
        }
    }
}

/// Render a TreeMap node (crate level only for now - methods shown as single rect).
#[component]
fn CrateNode(
    node: TreeMapNode,
    selected: Option<String>,
    on_select: EventHandler<String>,
    on_zoom: EventHandler<String>,
) -> Element {
    let rect = match &node.rect {
        Some(r) => r.clone(),
        None => {
            return rsx! { g {} };
        }
    };

    // Skip tiny rectangles
    if rect.width < 1.0 || rect.height < 1.0 {
        return rsx! { g {} };
    }

    let label = node.label.clone();
    let is_selected = selected.as_ref() == Some(&label);

    // Color: Purple for crates
    let color = "#805ad5";
    let stroke_color = if is_selected { "#f56565" } else { "#2d3748" };
    let stroke_width = if is_selected { "2" } else { "1" };

    // Calculate opacity based on size
    let opacity = 0.7 + (rect.area() / (WINDOW_WIDTH * WINDOW_HEIGHT) * 0.3).min(0.3);

    let value = node.value;
    let label_for_enter = label.clone();
    let label_for_click = label.clone();
    let label_text = label.clone();

    rsx! {
        g {
            // Rectangle
            rect {
                x: "{rect.x}",
                y: "{rect.y}",
                width: "{rect.width}",
                height: "{rect.height}",
                fill: color,
                stroke: stroke_color,
                stroke_width: stroke_width,
                opacity: "{opacity}",
                cursor: "pointer",
                onmouseenter: move |_| {
                    on_select.call(label_for_enter.clone());
                },
                onclick: move |_| {
                    on_zoom.call(label_for_click.clone());
                },
                // Tooltip
                title { "{label_text} ({value} calls)" }
            }

            // Label (if rectangle is large enough)
            if rect.width > 50.0 && rect.height > 20.0 {
                text {
                    x: "{rect.x + rect.width / 2.0}",
                    y: "{rect.y + rect.height / 2.0}",
                    text_anchor: "middle",
                    dominant_baseline: "middle",
                    font_size: "10",
                    fill: "white",
                    pointer_events: "none",
                    style: "user-select: none;",
                    "{truncate_label(&label, rect.width)}"
                }
            }
        }
    }
}

/// Render the information panel for the selected node.
fn render_info_panel(label: &str, analysis: &ProjectAnalysis) -> Element {
    // Try to find info about this crate or method
    let info = if let Some(crate_usage) = analysis.crates.get(label) {
        format!(
            "Crate: {}\nType: {}\nTotal calls: {}\nMethods: {}",
            crate_usage.name,
            if crate_usage.is_direct { "Direct" } else { "Transitive" },
            crate_usage.total_calls,
            crate_usage.methods.len()
        )
    } else {
        // Try to find as a method
        for (crate_name, crate_usage) in &analysis.crates {
            if let Some(method) = crate_usage.methods.get(label) {
                return rsx! {
                    div { class: "info-panel",
                        h3 { "Method: {method.path}" }
                        p { "Crate: {crate_name}" }
                        p { "Calls: {method.call_count}" }
                        if method.call_sites.len() <= 10 {
                            details {
                                summary { "Call sites ({method.call_sites.len()})" }
                                ul {
                                    for site in method.call_sites.iter() {
                                        li { "{site.file_path}:{site.line}:{site.column}" }
                                    }
                                }
                            }
                        } else {
                            p { "{method.call_sites.len()} call sites (too many to display)" }
                        }
                    }
                };
            }
        }
        format!("Selected: {}", label)
    };

    rsx! {
        div { class: "info-panel",
            pre { "{info}" }
        }
    }
}

/// Truncate label to fit within a given width.
fn truncate_label(label: &str, width: f64) -> String {
    let max_chars = (width / 8.0) as usize; // Rough estimate: 8px per char
    if label.len() <= max_chars {
        label.to_string()
    } else if max_chars > 3 {
        format!("{}...", &label[..max_chars - 3])
    } else {
        String::new()
    }
}

/// Launch the Dioxus desktop application.
pub fn launch(analysis: ProjectAnalysis) {
    // Store analysis in global state
    {
        let mut data = ANALYSIS_DATA.lock().unwrap();
        *data = Some(Arc::new(analysis));
    }

    // Launch the app
    dioxus::launch(App);
}