tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Selectable list component with focus-aware styling.
//!
//! Provides a unified list rendering pattern that handles:
//! - Focus-aware border coloring
//! - Highlight styling for selected items
//! - Consistent highlight symbols

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    widgets::{List, ListItem, ListState, StatefulWidget},
};

use crate::tui::theme;

/// Trait for types that can be rendered as list items.
///
/// Implement this trait for data types that should be displayed in a `SelectableList`.
///
/// # Examples
/// ```ignore
/// impl ListItemRenderable for SessionItem {
///     fn to_list_item(&self) -> ListItem<'_> {
///         ListItem::new(Line::from(vec![
///             Span::styled("● ", Style::default().fg(Color::Green)),
///             Span::raw(&self.name),
///         ]))
///     }
/// }
/// ```
pub trait ListItemRenderable {
    /// Convert this item to a `ListItem` for rendering.
    fn to_list_item(&self) -> ListItem<'_>;
}

/// A list widget with focus-aware styling.
///
/// Wraps ratatui's `List` with consistent styling based on focus state:
/// - Focused: Cyan border, "> " highlight symbol, bold selection
/// - Unfocused: Gray border, no highlight symbol, no selection style
///
/// # Type Parameters
/// - `T`: The item type, must implement `ListItemRenderable`
///
/// # Examples
/// ```ignore
/// let list = SelectableList::new(&sessions, "Sessions (Enter: switch)")
///     .focused(self.section == PopupSection::Sessions);
/// frame.render_stateful_widget(list, area, &mut state.session_list);
/// ```
pub struct SelectableList<'a, T> {
    items: &'a [T],
    title: &'a str,
    focused: bool,
}

impl<'a, T> SelectableList<'a, T> {
    /// Create a new selectable list.
    ///
    /// # Arguments
    /// - `items`: Slice of items to display
    /// - `title`: Block title (displayed in border)
    #[must_use]
    pub fn new(items: &'a [T], title: &'a str) -> Self {
        Self {
            items,
            title,
            focused: false,
        }
    }

    /// Set whether this list is focused.
    ///
    /// Focus state affects border color, highlight style, and highlight symbol.
    #[must_use]
    pub fn focused(mut self, focused: bool) -> Self {
        self.focused = focused;
        self
    }
}

impl<T: ListItemRenderable> StatefulWidget for SelectableList<'_, T> {
    type State = ListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let items: Vec<ListItem<'_>> = self
            .items
            .iter()
            .map(ListItemRenderable::to_list_item)
            .collect();

        let list = List::new(items)
            .block(theme::block(
                self.title,
                if self.focused {
                    theme::BlockVariant::Focused
                } else {
                    theme::BlockVariant::Unfocused
                },
            ))
            .highlight_style(theme::highlight_style(self.focused))
            .highlight_symbol(theme::highlight_symbol(self.focused));

        StatefulWidget::render(list, area, buf, state);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::test_utils::{buffer_to_text, mock_list_state};
    use ratatui::{
        style::{Color, Style},
        text::{Line, Span},
    };

    /// Render stateful widget to plain text (no style info).
    fn render_stateful_to_text<W, S>(widget: W, state: &mut S, width: u16, height: u16) -> String
    where
        W: StatefulWidget<State = S>,
    {
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        widget.render(area, &mut buf, state);
        buffer_to_text(&buf)
    }

    /// Test item implementing `ListItemRenderable`
    struct TestItem {
        name: String,
        color: Color,
    }

    impl TestItem {
        fn new(name: &str, color: Color) -> Self {
            Self {
                name: name.to_string(),
                color,
            }
        }
    }

    impl ListItemRenderable for TestItem {
        fn to_list_item(&self) -> ListItem<'_> {
            ListItem::new(Line::from(vec![
                Span::styled("", Style::default().fg(self.color)),
                Span::raw(&self.name),
            ]))
        }
    }

    #[test]
    fn renders_empty_list() {
        let items: Vec<TestItem> = vec![];
        let list = SelectableList::new(&items, "Empty List").focused(true);
        let mut state = mock_list_state(None);

        let text = render_stateful_to_text(list, &mut state, 40, 5);
        assert!(text.contains("Empty List"), "Text:\n{text}");
    }

    #[test]
    fn renders_items_with_colors() {
        let items = vec![
            TestItem::new("Item 1", Color::Green),
            TestItem::new("Item 2", Color::Red),
        ];
        let list = SelectableList::new(&items, "Test List").focused(true);
        let mut state = mock_list_state(Some(0));

        let text = render_stateful_to_text(list, &mut state, 40, 6);
        assert!(text.contains("Item 1"), "Text:\n{text}");
        assert!(text.contains("Item 2"), "Text:\n{text}");
    }

    #[test]
    fn focused_list_shows_highlight_symbol() {
        let items = vec![TestItem::new("Selected", Color::Green)];
        let list = SelectableList::new(&items, "Focused").focused(true);
        let mut state = mock_list_state(Some(0));

        let text = render_stateful_to_text(list, &mut state, 40, 5);
        // When focused and selected, should show "> "
        assert!(
            text.contains("> "),
            "Expected highlight symbol in focused list:\n{text}"
        );
    }

    #[test]
    fn unfocused_list_hides_highlight_symbol() {
        let items = vec![TestItem::new("Selected", Color::Green)];
        let list = SelectableList::new(&items, "Unfocused").focused(false);
        let mut state = mock_list_state(Some(0));

        let text = render_stateful_to_text(list, &mut state, 40, 5);
        // When unfocused, should not show "> " before item
        assert!(
            !text.contains("> ●"),
            "Unfocused list should not show highlight symbol:\n{text}"
        );
    }

    #[test]
    fn list_title_appears_in_border() {
        let items = vec![TestItem::new("Test", Color::White)];
        let list = SelectableList::new(&items, "Custom Title").focused(false);
        let mut state = mock_list_state(None);

        let text = render_stateful_to_text(list, &mut state, 40, 5);
        assert!(text.contains("Custom Title"), "Text:\n{text}");
    }
}