kanban-tui 0.7.2

Terminal user interface for the kanban project management tool
Documentation
use ratatui::{layout::Rect, text::Line, widgets::Paragraph, Frame};

pub struct ListItem<T> {
    pub value: T,
    pub label: Line<'static>,
    pub selectable: bool,
}

impl<T> ListItem<T> {
    pub fn selectable(value: T, label: Line<'static>) -> Self {
        Self {
            value,
            label,
            selectable: true,
        }
    }

    pub fn header(value: T, label: Line<'static>) -> Self {
        Self {
            value,
            label,
            selectable: false,
        }
    }
}

type StickyHeaderFn<'a, T> =
    Box<dyn Fn(&[ListItem<T>], usize) -> Option<(usize, Line<'static>)> + 'a>;

pub struct RadioList<'a, T> {
    items: &'a [ListItem<T>],
    sticky_header_for: Option<StickyHeaderFn<'a, T>>,
}

impl<'a, T> RadioList<'a, T> {
    pub fn new(items: &'a [ListItem<T>]) -> Self {
        Self {
            items,
            sticky_header_for: None,
        }
    }

    pub fn with_sticky_header<F>(mut self, f: F) -> Self
    where
        F: Fn(&[ListItem<T>], usize) -> Option<(usize, Line<'static>)> + 'a,
    {
        self.sticky_header_for = Some(Box::new(f));
        self
    }

    pub fn render(&self, frame: &mut Frame, area: Rect, selected: Option<usize>) {
        let lines: Vec<Line<'static>> = self.items.iter().map(|i| i.label.clone()).collect();
        let total = lines.len();
        let height = area.height as usize;
        let sel = selected.unwrap_or(0);
        let scroll = scroll_offset_to_show(sel, total, height);
        let list = Paragraph::new(lines).scroll((scroll as u16, 0));
        frame.render_widget(list, area);

        if let Some(f) = &self.sticky_header_for {
            if area.height == 0 {
                return;
            }
            if let Some((header_idx, label)) = f(self.items, sel) {
                if header_idx < scroll {
                    let overlay = Paragraph::new(label);
                    let top_row = Rect {
                        x: area.x,
                        y: area.y,
                        width: area.width,
                        height: 1,
                    };
                    frame.render_widget(overlay, top_row);
                }
            }
        }
    }

    pub fn next_selectable(&self, cur: Option<usize>) -> Option<usize> {
        crate::components::list_nav::next_selectable_index(cur, self.items.len(), |i| {
            self.items[i].selectable
        })
    }

    pub fn prev_selectable(&self, cur: Option<usize>) -> Option<usize> {
        crate::components::list_nav::prev_selectable_index(cur, self.items.len(), |i| {
            self.items[i].selectable
        })
    }

    pub fn first_selectable(&self) -> Option<usize> {
        crate::components::list_nav::first_selectable_index(self.items.len(), |i| {
            self.items[i].selectable
        })
    }
}

pub fn scroll_offset_to_show(selected: usize, total: usize, height: usize) -> usize {
    if height == 0 || total <= height || selected < height {
        return 0;
    }
    let max_offset = total.saturating_sub(height);
    (selected + 1).saturating_sub(height).min(max_offset)
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::backend::TestBackend;
    use ratatui::style::{Color, Modifier, Style};
    use ratatui::text::Span;
    use ratatui::Terminal;

    fn item(value: &'static str, label: &'static str) -> ListItem<&'static str> {
        ListItem::selectable(value, Line::from(label.to_string()))
    }

    fn header_item(label: &'static str) -> ListItem<&'static str> {
        ListItem::header("", Line::from(label.to_string()))
    }

    fn render_to_buffer(
        list: &RadioList<&'static str>,
        selected: Option<usize>,
        width: u16,
        height: u16,
    ) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| {
                let area = Rect::new(0, 0, width, height);
                list.render(frame, area, selected);
            })
            .unwrap();
        let buffer = terminal.backend().buffer().clone();
        let mut out = String::new();
        for y in 0..buffer.area.height {
            for x in 0..buffer.area.width {
                out.push_str(buffer.cell((x, y)).map(|c| c.symbol()).unwrap_or(" "));
            }
            out.push('\n');
        }
        out
    }

    #[test]
    fn test_radio_list_render_emits_one_line_per_item() {
        let items = vec![item("a", "alpha"), item("b", "bravo"), item("c", "charlie")];
        let list = RadioList::new(&items);
        let out = render_to_buffer(&list, Some(0), 20, 5);
        assert!(out.contains("alpha"), "row 0 label must appear: {}", out);
        assert!(out.contains("bravo"), "row 1 label must appear: {}", out);
        assert!(out.contains("charlie"), "row 2 label must appear: {}", out);
    }

    #[test]
    fn test_radio_list_render_marks_selected_item_with_prefix() {
        let make_styled = |label: &'static str, is_selected: bool| {
            let prefix = if is_selected { "> " } else { "  " };
            ListItem::selectable(label, Line::from(format!("{}{}", prefix, label)))
        };
        let items = vec![
            make_styled("alpha", false),
            make_styled("bravo", true),
            make_styled("charlie", false),
        ];
        let list = RadioList::new(&items);
        let out = render_to_buffer(&list, Some(1), 20, 5);
        assert!(
            out.contains("> bravo"),
            "selected row should have arrow prefix: {}",
            out
        );
        assert!(
            out.contains("  alpha"),
            "non-selected row should have space prefix: {}",
            out
        );
    }

    #[test]
    fn test_radio_list_next_selectable_skips_non_selectable_items() {
        let items = vec![
            item("a", "alpha"),
            header_item("--- HDR ---"),
            item("b", "bravo"),
            header_item("--- HDR2 ---"),
            item("c", "charlie"),
        ];
        let list = RadioList::new(&items);
        assert_eq!(list.next_selectable(Some(0)), Some(2));
        assert_eq!(list.next_selectable(Some(2)), Some(4));
    }

    #[test]
    fn test_radio_list_prev_selectable_skips_non_selectable_items() {
        let items = vec![
            item("a", "alpha"),
            header_item("--- HDR ---"),
            item("b", "bravo"),
            header_item("--- HDR2 ---"),
            item("c", "charlie"),
        ];
        let list = RadioList::new(&items);
        assert_eq!(list.prev_selectable(Some(4)), Some(2));
        assert_eq!(list.prev_selectable(Some(2)), Some(0));
    }

    #[test]
    fn test_radio_list_first_selectable_returns_first_selectable_index() {
        let items = vec![
            header_item("--- HDR ---"),
            item("a", "alpha"),
            item("b", "bravo"),
        ];
        let list = RadioList::new(&items);
        assert_eq!(list.first_selectable(), Some(1));
    }

    #[test]
    fn test_radio_list_with_sticky_header_overlay_renders_label_when_selection_scrolls_past_header()
    {
        let mut items = vec![item("0", "row0")];
        items.push(header_item("STICKY"));
        for i in 1..=10 {
            items.push(item("x", Box::leak(format!("row{}", i).into_boxed_str())));
        }
        let list = RadioList::new(&items).with_sticky_header(|entries, idx| {
            for i in (0..idx).rev() {
                if !entries[i].selectable {
                    let label_text = entries[i]
                        .label
                        .spans
                        .first()
                        .map(|s| s.content.to_string())
                        .unwrap_or_default();
                    return Some((
                        i,
                        Line::from(Span::styled(
                            label_text,
                            Style::default()
                                .fg(Color::Yellow)
                                .add_modifier(Modifier::BOLD),
                        )),
                    ));
                }
            }
            None
        });
        let out = render_to_buffer(&list, Some(8), 20, 5);
        assert!(
            out.contains("STICKY"),
            "sticky header overlay must show when selection scrolls past it: {}",
            out
        );
    }

    #[test]
    fn test_scroll_offset_is_zero_when_list_fits_in_viewport() {
        assert_eq!(scroll_offset_to_show(0, 3, 5), 0);
        assert_eq!(scroll_offset_to_show(2, 3, 5), 0);
        assert_eq!(scroll_offset_to_show(5, 6, 6), 0);
    }

    #[test]
    fn test_scroll_offset_is_zero_when_selected_is_in_first_viewport() {
        assert_eq!(scroll_offset_to_show(0, 20, 5), 0);
        assert_eq!(scroll_offset_to_show(4, 20, 5), 0);
    }

    #[test]
    fn test_scroll_offset_keeps_selected_visible_when_below_viewport() {
        assert_eq!(scroll_offset_to_show(5, 20, 5), 1);
        assert_eq!(scroll_offset_to_show(10, 20, 5), 6);
    }

    #[test]
    fn test_scroll_offset_clamps_at_last_full_viewport() {
        assert_eq!(scroll_offset_to_show(19, 20, 5), 15);
    }

    #[test]
    fn test_scroll_offset_handles_zero_height() {
        assert_eq!(scroll_offset_to_show(5, 20, 0), 0);
    }

    #[test]
    fn test_scroll_offset_handles_empty_list() {
        assert_eq!(scroll_offset_to_show(0, 0, 5), 0);
    }
}