tui-lipan 0.1.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
use super::{StateDiagram, StateKind};
use crate::widgets::common::diagram_text::wrap_label;
use crate::widgets::common::simple_diagram::{
    EndpointGlyph, SimpleDiagramBox, SimpleDiagramBoxShape, SimpleDiagramEdge, SimpleDiagramOutput,
    build_simple_diagram_output,
};
use std::sync::Arc;

pub fn measure_state_diagram(diagram: &StateDiagram) -> (u16, u16) {
    let output = build_state_diagram_output(diagram);
    (
        output.width.saturating_add(diagram.padding.horizontal()),
        output.height.saturating_add(diagram.padding.vertical()),
    )
}
pub(crate) fn build_state_diagram_output(diagram: &StateDiagram) -> SimpleDiagramOutput {
    let boxes = state_boxes(diagram);
    let edges = state_edges(diagram);
    build_simple_diagram_output(
        &boxes,
        &edges,
        diagram.node_padding,
        diagram.layer_gap,
        diagram.node_gap,
    )
}
pub(crate) fn state_boxes(diagram: &StateDiagram) -> Vec<SimpleDiagramBox> {
    diagram
        .states
        .iter()
        .map(|state| {
            let mut rows = Vec::new();
            match state.kind {
                StateKind::Start => rows.push(diagram.theme.start.to_string().into()),
                StateKind::End => rows.push(diagram.theme.end.to_string().into()),
                StateKind::Choice => rows.push(diagram.theme.choice.to_string().into()),
                StateKind::Fork | StateKind::Join => {
                    rows.push(diagram.theme.fork_join.to_string().repeat(3).into())
                }
                StateKind::State => {
                    push_wrapped(&mut rows, &state.label, diagram.max_node_width);
                    if let Some(entry) = &state.entry {
                        push_wrapped(
                            &mut rows,
                            &format!("entry / {entry}"),
                            diagram.max_node_width,
                        );
                    }
                    if let Some(exit) = &state.exit {
                        push_wrapped(&mut rows, &format!("exit / {exit}"), diagram.max_node_width);
                    }
                }
            }
            SimpleDiagramBox {
                id: state.id.clone(),
                rows,
                divider_after: Vec::new(),
                fill_style: diagram.state_style,
                border_style_fg: diagram.state_style,
                label_style: diagram.state_style,
                border_style: diagram.border_style,
                shape: SimpleDiagramBoxShape::Rect,
            }
        })
        .collect()
}

fn push_wrapped(rows: &mut Vec<Arc<str>>, text: &str, max_width: u16) {
    rows.extend(wrap_label(text, max_width).iter().cloned());
}

pub(crate) fn state_edges(diagram: &StateDiagram) -> Vec<SimpleDiagramEdge> {
    diagram
        .transitions
        .iter()
        .map(|t| {
            let label: Option<Arc<str>> = match (&t.label, &t.guard) {
                (Some(label), Some(guard)) => Some(format!("{label} [{guard}]").into()),
                (Some(label), None) => Some(label.clone()),
                (None, Some(guard)) => Some(format!("[{guard}]").into()),
                (None, None) => None,
            };
            SimpleDiagramEdge {
                from: t.from.clone(),
                to: t.to.clone(),
                label,
                from_label: None,
                to_label: None,
                line_style: diagram.edge_style,
                label_style: diagram.label_style,
                dashed: false,
                from_glyph: EndpointGlyph::None,
                to_glyph: EndpointGlyph::Arrow,
                prefer_vertical_backedge_labels: true,
            }
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::widgets::StateSpec;

    #[test]
    fn state_rows_wrap_before_measurement() {
        let diagram = StateDiagram::new()
            .states([StateSpec::new("processing").label("Waiting for external approval")])
            .max_node_width(10);

        let boxes = state_boxes(&diagram);
        let state = &boxes[0];

        assert!(state.rows.len() > 1);
        assert!(state.rows.iter().all(|row| row.chars().count() <= 10));
    }

    #[test]
    fn state_entry_exit_rows_wrap_before_measurement() {
        let diagram = StateDiagram::new()
            .states([StateSpec::new("processing")
                .entry("hydrate cache before rendering")
                .exit("flush metrics after update")])
            .max_node_width(12);

        let boxes = state_boxes(&diagram);
        let state = &boxes[0];

        assert!(state.rows.len() > 3);
        assert!(state.rows.iter().all(|row| row.chars().count() <= 12));
    }
}