hefesto-widgets 0.7.3

Ratatui widgets for the Hefesto TUI toolkit: popups, scrollable lists, trees, text input and spinners
Documentation
use std::collections::HashSet;

use super::*;
use crate::tree::TreeNode;
use insta::assert_snapshot;

use crate::{Badge, BadgeAnchor, BadgeStack};
use ratatui::{backend::TestBackend, Terminal};

fn render(name: &str, popup: TreePopup<'_>, state: &mut TreeState) {
    let backend = TestBackend::new(60, 20);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal
        .draw(|f| f.render_stateful_widget(popup, f.area(), state))
        .unwrap();
    assert_snapshot!(name, terminal.backend());
}

fn sample_nodes() -> Vec<TreeNode<'static>> {
    vec![
        TreeNode {
            text: "src".into(),
            children: vec![
                TreeNode { text: "main.rs".into(), children: vec![], id: 2 },
                TreeNode { text: "lib.rs".into(), children: vec![], id: 3 },
            ],
            id: 1,
        },
        TreeNode {
            text: "Cargo.toml".into(),
            children: vec![],
            id: 4,
        },
    ]
}

// ── resolve_rect ──

#[test]
fn resolve_rect_delegates_to_popup() {
    let popup = TreePopup::new(sample_nodes())
        .width(PopupSize::Fixed(30))
        .height(PopupSize::Fixed(10));
    let state = TreeState::default();
    assert_eq!(popup.resolve_rect(Rect::new(0, 0, 100, 50), &state).width, 30);
    assert_eq!(popup.resolve_rect(Rect::new(0, 0, 100, 50), &state).height, 10);
}

#[test]
fn resolve_rect_clamps_when_larger_than_area() {
    let popup = TreePopup::new(sample_nodes())
        .width(PopupSize::Fixed(100))
        .height(PopupSize::Fixed(50));
    let state = TreeState::default();
    assert_eq!(
        popup.resolve_rect(Rect::new(0, 0, 60, 20), &state),
        Rect::new(0, 0, 60, 20)
    );
}

#[test]
fn resolve_rect_auto_uses_auto_height() {
    let popup = TreePopup::new(sample_nodes());
    let state = TreeState::default();
    let auto_h = popup.auto_height(&state, Rect::new(0, 0, 100, 50));
    // visible_count = 2 root nodes, tree_height = 2 + 2 = 4, max(4, 5) = 5
    assert_eq!(auto_h, 5);
    let rect = popup.resolve_rect(Rect::new(0, 0, 100, 50), &state);
    assert_eq!(rect.height, 5);
}

#[test]
fn resolve_rect_auto_centers_correctly() {
    let popup = TreePopup::new(sample_nodes());
    let state = TreeState::default();
    let rect = popup.resolve_rect(Rect::new(0, 0, 100, 50), &state);
    // height = 5, width = 80% of 100 = 80
    assert_eq!(rect.x, 10);
    assert_eq!(rect.y, 22); // (50 - 5) / 2
}

// ── snapshots ──

#[test]
fn snapshot_default() {
    let popup = TreePopup::new(sample_nodes());
    let mut state = TreeState::default();
    render("tree_popup_default", popup, &mut state);
}

#[test]
fn snapshot_with_title() {
    let popup = TreePopup::new(sample_nodes()).title("Project");
    let mut state = TreeState::default();
    render("tree_popup_with_title", popup, &mut state);
}

#[test]
fn snapshot_with_border_color() {
    let popup = TreePopup::new(sample_nodes())
        .title("Cyan border")
        .border_color(Color::Cyan);
    let mut state = TreeState::default();
    render("tree_popup_border_color", popup, &mut state);
}

#[test]
fn snapshot_with_width() {
    let popup = TreePopup::new(sample_nodes())
        .width(PopupSize::Fixed(40));
    let mut state = TreeState::default();
    render("tree_popup_with_width", popup, &mut state);
}

#[test]
fn snapshot_expanded() {
    let popup = TreePopup::new(sample_nodes()).title("Expanded");
    let mut state = TreeState { expanded: HashSet::from([1]), ..TreeState::default() };
    render("tree_popup_expanded", popup, &mut state);
}

#[test]
fn snapshot_with_origin() {
    let popup = TreePopup::new(sample_nodes())
        .origin(5, 5);
    let mut state = TreeState::default();
    render("tree_popup_with_origin", popup, &mut state);
}

/// Regression: the inner area was split [Min(0), Min(0)] but only
/// chunks[0] was used, wasting the bottom half. The tree must render
/// into the full inner. This test renders a 4-item tree (expanded)
/// and asserts the last inner row has tree content (was blank before).
#[test]
fn regression_tree_uses_full_inner() {
    let mut state = TreeState { expanded: HashSet::from([1]), ..TreeState::default() };
    let backend = TestBackend::new(60, 20);
    let mut terminal = Terminal::new(backend).unwrap();
    terminal
        .draw(|f| {
            f.render_stateful_widget(
                TreePopup::new(sample_nodes()).title("Expanded"),
                f.area(),
                &mut state,
            )
        })
        .unwrap();
    let buf = terminal.backend().buffer();

    // visible_count = 4 → total_height = max(6, 5) = 6
    let state = TreeState::default();
    let popup_rect = TreePopup::new(sample_nodes())
        .height(PopupSize::Fixed(6))
        .title("Expanded")
        .resolve_rect(Rect::new(0, 0, 60, 20), &state);

    // Inner = popup minus 1px border on each side
    let inner = Rect::new(
        popup_rect.x + 1,
        popup_rect.y + 1,
        popup_rect.width.saturating_sub(2),
        popup_rect.height.saturating_sub(2),
    );

    let last_y = inner.y + inner.height - 1;

    let has_content = (inner.x + 1..inner.x + inner.width - 1)
        .any(|x| buf.cell((x, last_y)).is_some_and(|c| c.symbol().trim() != ""));

    assert!(
        has_content,
        "last inner row (y={}) should contain tree content, not be blank",
        last_y,
    );
}

// ── badges integration ──

#[test]
fn snapshot_with_badges() {
    let badges = BadgeStack::new()
        .push(Badge::new(" main", Color::Green).anchor(BadgeAnchor::TopLeft));
    let popup = TreePopup::new(sample_nodes())
        .title("Files")
        .badges(badges);
    let mut state = TreeState::default();
    render("tree_with_badges", popup, &mut state);
}