tix-tracker 0.12.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,
    /// The tab index currently hovered by the pointer, if any.
    pub hovered: Option<usize>,
}

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

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

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

    /// Whether the tab bar must be rendered at all: with only one named board
    /// there is nothing to switch between, so the selector is omitted.
    pub fn needs_bar(&self) -> bool {
        self.names.len() > 1
    }

    /// 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, _)| self.title(i).unwrap_or_default())
            .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);
    }

    /// The rendered title of tab `index` — bracketed when it is the active
    /// tab — matching what `Tabs` draws. `None` when `index` is out of range.
    pub fn title(&self, index: usize) -> Option<String> {
        self.names.get(index).map(|name| {
            if index == self.selected {
                format!("[{name}]")
            } else if self.hovered == Some(index) {
                format!(">{name}<")
            } else {
                name.clone()
            }
        })
    }

    /// The hit region of tab `index` within `area`, mirroring the
    /// left-to-right, space-separated layout `render` produces. `None` when
    /// `index` is out of range.
    pub fn tab_rect(&self, index: usize, area: Rect) -> Option<Rect> {
        let width = self.title(index)?.chars().count() as u16;
        let offset: u16 = (0..index)
            .map(|i| {
                self.title(i)
                    .map(|t| t.chars().count() as u16 + 1)
                    .unwrap_or(1)
            })
            .sum();
        Some(Rect::new(area.x + offset, area.y, width, area.height))
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    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 single_board_needs_no_bar() {
        assert!(!BoardTabs::new(vec!["default".into()]).needs_bar());
        assert!(!BoardTabs::new(vec![]).needs_bar());
    }

    #[test]
    fn multiple_boards_need_a_bar() {
        assert!(BoardTabs::new(vec!["default".into(), "release".into()]).needs_bar());
    }

    #[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");
    }

    #[test]
    fn render_hovered_tab_uses_hover_style() {
        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        let mut tabs = BoardTabs::new(vec!["default".into(), "release".into()]);
        tabs.hovered = Some(1);
        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<"),
            "hovered tab should be marked: {content:?}"
        );
    }
}