tui-treelistview 0.2.1

Interactive tree list widget for Ratatui
Documentation
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::widgets::{Cell, StatefulWidget};
use tui_treelistview::{
    ColumnDef, ColumnWidth, TreeChildren, TreeColumnSet, TreeHit, TreeLabelPrefix,
    TreeLabelProvider, TreeListView, TreeListViewState, TreeListViewStyle, TreeModel, TreeQuery,
    TreeRevision, TreeRowContext, TreeRowRendering,
};

struct Model {
    children: Vec<Vec<usize>>,
    names: Vec<String>,
}

impl Model {
    fn sample() -> Self {
        Self {
            children: vec![vec![1, 2, 3, 4, 5], vec![], vec![], vec![], vec![], vec![]],
            names: ["root", "alpha", "beta", "gamma", "delta", "epsilon"]
                .map(str::to_owned)
                .into(),
        }
    }
}

impl TreeModel for Model {
    type Id = usize;

    fn roots(&self) -> impl Iterator<Item = Self::Id> + '_ {
        std::iter::once(0)
    }

    fn children(&self, id: Self::Id) -> TreeChildren<'_, Self::Id> {
        TreeChildren::loaded(&self.children[id])
    }

    fn revision(&self) -> TreeRevision {
        TreeRevision::INITIAL
    }

    fn size_hint(&self) -> usize {
        self.children.len()
    }
}

struct Label;

impl TreeLabelProvider<Model> for Label {
    fn label_parts<'a>(&'a self, model: &'a Model, id: usize) -> TreeLabelPrefix<'a> {
        TreeLabelPrefix::borrowed(&model.names[id])
    }
}

fn columns(show_header: bool) -> TreeColumnSet<'static, Model> {
    let separator = String::from(":");
    let columns = TreeColumnSet::new([
        ColumnDef::data_owned(
            "Detail",
            ColumnWidth::fixed(12),
            move |model: &Model, id: usize, context: &TreeRowContext<'_>| {
                Cell::from(format!("{}{separator}{}", context.level, model.names[id]))
            },
        ),
        ColumnDef::tree("Name", ColumnWidth::fixed(12)),
    ])
    .expect("one tree column");
    if show_header {
        columns
    } else {
        columns.without_header()
    }
}

fn rendered(rendering: TreeRowRendering, offset: usize) -> Buffer {
    let model = Model::sample();
    let query = TreeQuery::new();
    let columns = columns(false);
    let label = Label;
    let mut state = TreeListViewState::new();
    let _ = state.expand_all(&model);
    let _ = state.ensure_projection(&model, &query);
    let _ = state.set_offset(offset);
    let area = Rect::new(0, 0, 20, 4);
    let mut buffer = Buffer::empty(area);
    TreeListView::new(
        &model,
        &query,
        &label,
        &columns,
        TreeListViewStyle {
            row_rendering: rendering,
            ..TreeListViewStyle::borderless()
        },
    )
    .render(area, &mut buffer, &mut state);
    buffer
}

#[test]
fn virtualized_and_full_rendering_are_identical_at_every_viewport_position() {
    for offset in [0, 2, 5] {
        assert_eq!(
            rendered(TreeRowRendering::Virtualized, offset),
            rendered(TreeRowRendering::Full, offset),
            "rendering differs at offset {offset}"
        );
    }
}

#[test]
fn hit_testing_reports_headers_rows_columns_and_scrollbars() {
    let model = Model::sample();
    let query = TreeQuery::new();
    let columns = columns(true);
    let label = Label;
    let mut state = TreeListViewState::new();
    let _ = state.expand_all(&model);
    let area = Rect::new(4, 2, 22, 4);
    let mut buffer = Buffer::empty(area);
    TreeListView::new(
        &model,
        &query,
        &label,
        &columns,
        TreeListViewStyle::borderless(),
    )
    .render(area, &mut buffer, &mut state);

    assert_eq!(
        state.hit_test(Position::new(7, 2)),
        Some(TreeHit::Header { column: Some(0) })
    );
    assert_eq!(
        state.hit_test(Position::new(20, 3)),
        Some(TreeHit::Row {
            id: 0,
            index: 0,
            column: Some(1),
        })
    );
    assert_eq!(
        state.hit_test(Position::new(25, 2)),
        Some(TreeHit::VerticalScrollbar)
    );
    assert_eq!(
        state.hit_test(Position::new(4, 5)),
        Some(TreeHit::HorizontalScrollbar)
    );
    assert_eq!(state.hit_test(Position::new(3, 2)), None);
}

#[test]
fn rendering_clamps_the_offset_to_the_last_full_viewport() {
    let model = Model::sample();
    let query = TreeQuery::new();
    let columns = columns(false);
    let label = Label;
    let mut state = TreeListViewState::new();
    let _ = state.expand_all(&model);
    let _ = state.ensure_projection(&model, &query);
    let _ = state.set_offset(usize::MAX);
    let area = Rect::new(0, 0, 20, 4);
    let mut buffer = Buffer::empty(area);
    TreeListView::new(
        &model,
        &query,
        &label,
        &columns,
        TreeListViewStyle::borderless(),
    )
    .render(area, &mut buffer, &mut state);

    assert_eq!(state.offset(), 3);
    assert_eq!(
        buffer.cell((3, 0)).map(ratatui::buffer::Cell::symbol),
        Some("1")
    );
    assert_eq!(
        buffer.cell((16, 0)).map(ratatui::buffer::Cell::symbol),
        Some("")
    );
}