tix-tracker 0.2.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Persistent tabs bar of named boards (T015 / WP03).
//!
//! A thin wrapper over ratatui's `Tabs` widget listing every named
//! configuration in declaration order (tab order = declaration order, D-02).
//! The first tab is the default active board (DM-01M2MH8J01DZHV3JMT76XX38KX)
//! and the selected index is app-session state only — never persisted.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Tabs};

/// The persistent tabs bar widget: config names + the active index.
#[derive(Debug, Clone, Default)]
pub struct BoardTabs {
    /// Named boards in declaration order.
    pub names: Vec<String>,
    /// The active tab index (session state, never persisted).
    pub selected: usize,
}

impl BoardTabs {
    pub fn new(names: Vec<String>) -> Self {
        Self { names, selected: 0 }
    }

    /// The number of named boards.
    pub fn len(&self) -> usize {
        self.names.len()
    }

    pub fn is_empty(&self) -> bool {
        self.names.is_empty()
    }

    /// Move the active tab forward, wrapping to the first; `1`+ board only.
    pub fn next(&mut self) {
        if !self.names.is_empty() {
            self.selected = (self.selected + 1) % self.names.len();
        }
    }

    /// Move the active tab backward, wrapping to the last; `1`+ board only.
    pub fn previous(&mut self) {
        if self.names.is_empty() {
            return;
        }
        self.selected = if self.selected == 0 {
            self.names.len() - 1
        } else {
            self.selected - 1
        };
    }

    pub fn render(&self, f: &mut Frame, area: Rect) {
        if area.width == 0 || area.height == 0 {
            return;
        }
        let titles: Vec<String> = self
            .names
            .iter()
            .enumerate()
            .map(|(i, n)| {
                if i == self.selected {
                    format!("[{n}]")
                } else {
                    n.clone()
                }
            })
            .collect();
        let tabs = Tabs::new(titles)
            .block(Block::default().borders(Borders::BOTTOM))
            .highlight_style(Style::default().bold())
            .select(self.selected);
        f.render_widget(tabs, area);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    #[test]
    fn first_tab_is_default() {
        let tabs = BoardTabs::new(vec!["default".into(), "release".into(), "personal".into()]);
        assert_eq!(tabs.selected, 0);
    }

    #[test]
    fn next_wraps_forward() {
        let mut tabs = BoardTabs::new(vec!["a".into(), "b".into(), "c".into()]);
        tabs.next();
        tabs.next();
        assert_eq!(tabs.selected, 2);
        tabs.next();
        assert_eq!(tabs.selected, 0, "wraps to first tab");
    }

    #[test]
    fn previous_wraps_backward() {
        let mut tabs = BoardTabs::new(vec!["a".into(), "b".into(), "c".into()]);
        tabs.previous();
        assert_eq!(tabs.selected, 2, "wraps to last tab");
        tabs.previous();
        assert_eq!(tabs.selected, 1);
    }

    #[test]
    fn single_tab_stays_put() {
        let mut tabs = BoardTabs::new(vec!["only".into()]);
        tabs.next();
        assert_eq!(tabs.selected, 0);
        tabs.previous();
        assert_eq!(tabs.selected, 0);
    }

    #[test]
    fn empty_tabs_next_previous_are_noops() {
        let mut tabs = BoardTabs::new(vec![]);
        tabs.next();
        tabs.previous();
        assert_eq!(tabs.selected, 0);
    }

    #[test]
    fn render_80x24_shows_all_three_boards() {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        let tabs = BoardTabs::new(vec!["default".into(), "release".into(), "personal".into()]);
        terminal.draw(|f| tabs.render(f, f.area())).unwrap();
        let buffer = terminal.backend().buffer();
        let content: String = buffer.content.iter().map(|c| c.symbol()).collect();
        assert!(content.contains("default"));
        assert!(content.contains("release"));
        assert!(content.contains("personal"));
    }

    #[test]
    fn render_selected_highlight_marks_active_bracket() {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        let mut tabs = BoardTabs::new(vec!["default".into(), "release".into()]);
        tabs.next();
        terminal.draw(|f| tabs.render(f, f.area())).unwrap();
        let buffer = terminal.backend().buffer();
        let content: String = buffer.content.iter().map(|c| c.symbol()).collect();
        assert!(content.contains("[release]"), "active tab bracketed");
        assert!(!content.contains("[default]"), "inactive tab not bracketed");
    }
}