tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The `F11` Library page (tix #1593): one page over the definitions a user
//! creates once and reuses — saved views and issue/form templates.
//!
//! # Why a page and not another modal
//!
//! The `v` modal and the `F12` Config surface each own one of these registries
//! already, but both are *writers*: `v` saves the live board, Config edits
//! `tix.toml`. The Library is the *reader* — the place to see what exists, what
//! each record actually says, and which view the board is currently living in.
//! That is why it is a top-level mode like `F4`/`F5`/`F6` rather than a modal
//! over the board, and why it takes no key the board or the form already uses.
//!
//! # Read-only by contract
//!
//! Nothing on this page writes. The one action that changes anything at all is
//! `Enter` on a **view**, which applies it to the board — and applying a view is
//! read-only by C-005 of the saved-views mission (it writes neither an issue nor
//! `tix.toml`). Templates are listed and inspected here; creating *from* one
//! stays on its own surfaces (`tix create --template`, the MCP tool, and the F1
//! form's template row), because the stable-ID path is a CLI/MCP seam today and
//! wiring it into a page would be new plumbing rather than a reader.
//!
//! # Empty states are part of the page
//!
//! A key that silently does nothing is indistinguishable from a bug, so an empty
//! tab draws the sentence that says how to fill it (`v` on the board; `tix
//! template create`) instead of a blank pane.

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};

use crate::app::App;

/// Which registry the page is showing.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum LibraryTab {
    /// Saved views (`[views.<id>]`, tix #172).
    #[default]
    Views,
    /// Issue/form templates (`[issue_templates.<id>]`, tix #174).
    Templates,
}

impl LibraryTab {
    /// The tabs in display order.
    pub const ALL: [Self; 2] = [Self::Views, Self::Templates];

    /// The tab's label in the strip.
    pub const fn label(self) -> &'static str {
        match self {
            Self::Views => "Views",
            Self::Templates => "Templates",
        }
    }

    /// The tab's registry name, as `tix.toml` spells it.
    pub const fn section(self) -> &'static str {
        match self {
            Self::Views => "views",
            Self::Templates => "issue_templates",
        }
    }

    /// The noun for the list's title and for "no X yet" sentences.
    pub const fn noun(self) -> &'static str {
        match self {
            Self::Views => "view",
            Self::Templates => "template",
        }
    }

    /// The next tab, wrapping. `Tab` and `l` walk forward.
    pub const fn next(self) -> Self {
        match self {
            Self::Views => Self::Templates,
            Self::Templates => Self::Views,
        }
    }

    /// The previous tab, wrapping. `Shift+Tab` and `h` walk backward.
    pub const fn prev(self) -> Self {
        self.next()
    }

    /// What an empty tab says, including how to stop being empty.
    ///
    /// Kept here rather than in the app so the sentence is a property of the page
    /// a test can assert, not a string literal inside a render function.
    pub const fn empty_hint(self) -> &'static str {
        match self {
            Self::Views => {
                "No saved views yet. Press `v` on the board to save the live filter, \
                 sort and visibility as a named view."
            }
            Self::Templates => {
                "No templates yet. Create one with \
                 `tix template create --id <ID> --name <NAME> ...`."
            }
        }
    }

    /// The keys this tab answers to, shown under the strip.
    pub const fn hint(self) -> &'static str {
        match self {
            Self::Views => {
                "Tab: switch  j/k: move  Enter: apply to the board  q: back to the board"
            }
            Self::Templates => {
                "Tab: switch  j/k: move  q: back to the board  \
                 (`tix create --template <ID>` creates from one)"
            }
        }
    }
}

/// The page's own state: which tab, and which row the cursor is on.
///
/// One cursor for both tabs, clamped to the active tab's row count when the tab
/// changes — so switching tabs never leaves the cursor past the end of a shorter
/// list.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryState {
    /// The active tab.
    pub tab: LibraryTab,
    /// The selected row inside the active tab, `0`-based.
    pub selected: usize,
}

impl LibraryState {
    /// A fresh page on the Views tab with the cursor on the first row.
    pub fn new() -> Self {
        Self::default()
    }

    /// Move the cursor by `delta` rows, clamped to `rows` entries (which may be
    /// zero). Returns `true` when the cursor moved.
    pub fn move_selected(&mut self, delta: isize, rows: usize) -> bool {
        let last = rows.saturating_sub(1);
        let before = self.selected;
        let next = if delta.is_negative() {
            self.selected.saturating_sub(delta.unsigned_abs())
        } else {
            self.selected.saturating_add(delta as usize)
        };
        self.selected = next.min(last);
        self.selected != before
    }

    /// Clamp the cursor into `rows` entries, for use after the registry changed
    /// underneath the page (a view deleted from another surface, say).
    pub fn clamp_selected(&mut self, rows: usize) {
        self.selected = self.selected.min(rows.saturating_sub(1));
    }
}

/// One row of the list, as display data: the widget never reads a registry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LibraryEntry {
    /// The bold part of the row: a view's name, a template's id.
    pub title: String,
    /// The dimmed tail: scope, defaults count, `active`.
    pub subtitle: String,
    /// Whether this record is the board's live view (Views tab only).
    pub active: bool,
}

/// The `key: value` lines of the detail pane, plus its heading.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibraryDetail {
    /// The heading line, e.g. `View` or `Template`.
    pub heading: String,
    /// The `key: value` lines, in a stable order.
    pub lines: Vec<(String, String)>,
}

/// The style of a tab that is not active.
fn dim() -> Style {
    Style::default().fg(Color::DarkGray)
}

/// The tab strip: `F11 Library` on the left, then every tab, the active one cyan
/// and bold.
fn tab_line(active: LibraryTab) -> Line<'static> {
    let mut spans = vec![
        Span::styled("F11 Library", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw("  "),
    ];
    for (index, tab) in LibraryTab::ALL.iter().enumerate() {
        if index > 0 {
            spans.push(Span::raw("  "));
        }
        let style = if *tab == active {
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else {
            dim()
        };
        spans.push(Span::styled(format!("[{}]", tab.label()), style));
    }
    Line::from(spans)
}

/// Draw the page: the strip and its hint on two lines, then the list beside the
/// selected record's detail.
pub fn render(app: &App, f: &mut Frame, area: Rect) {
    let state = app.library_state();
    let entries = app.library_entries(state.tab);
    let detail = app.library_detail(state.tab, state.selected);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Length(1),
            Constraint::Min(1),
        ])
        .split(area);
    f.render_widget(Paragraph::new(tab_line(state.tab)), rows[0]);
    f.render_widget(
        Paragraph::new(Span::styled(state.tab.hint(), dim())),
        rows[1],
    );

    let body = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(45), Constraint::Min(24)])
        .split(rows[2]);

    let title = format!("{} ({})", state.tab.label(), entries.len());
    let list = Block::default().borders(Borders::ALL).title(title);
    if entries.is_empty() {
        // The empty state is the page's content, not a missing pane: same block,
        // sentence inside it, wrapped so a narrow terminal does not clip it.
        f.render_widget(
            Paragraph::new(state.tab.empty_hint())
                .block(list)
                .wrap(Wrap { trim: true })
                .style(dim()),
            body[0],
        );
    } else {
        let mut lines: Vec<Line<'static>> = Vec::new();
        for (index, entry) in entries.iter().enumerate() {
            let selected = index == state.selected;
            let mut spans = vec![Span::raw(if selected { "> " } else { "  " })];
            let title_style = if selected {
                Style::default().add_modifier(Modifier::BOLD)
            } else {
                Style::default()
            };
            spans.push(Span::styled(entry.title.clone(), title_style));
            if entry.active {
                spans.push(Span::styled("  [active]", Style::default().fg(Color::Cyan)));
            }
            lines.push(Line::from(spans));
            lines.push(Line::from(Span::styled(
                format!("    {}", entry.subtitle),
                dim(),
            )));
        }
        f.render_widget(Paragraph::new(lines).block(list), body[0]);
    }

    let detail_block = Block::default()
        .borders(Borders::ALL)
        .title(detail_title(state.tab, detail.as_ref()));
    match detail.as_ref() {
        Some(detail) => {
            let lines: Vec<Line<'static>> = detail
                .lines
                .iter()
                .map(|(key, value)| {
                    Line::from(vec![
                        Span::styled(format!("{key}: "), dim()),
                        Span::raw(value.clone()),
                    ])
                })
                .collect();
            f.render_widget(
                Paragraph::new(lines)
                    .block(detail_block)
                    .wrap(Wrap { trim: false }),
                body[1],
            );
        }
        None => f.render_widget(
            Paragraph::new(Span::styled(
                format!(
                    "Nothing selected. A {} has to exist before the page can describe it.",
                    state.tab.noun()
                ),
                dim(),
            ))
            .block(detail_block)
            .wrap(Wrap { trim: true }),
            body[1],
        ),
    }
}

/// The detail pane's title: the record's own name, or the tab it belongs to.
fn detail_title(tab: LibraryTab, detail: Option<&LibraryDetail>) -> String {
    match detail {
        Some(detail) => match detail.lines.first() {
            Some((_, value)) if !value.is_empty() => format!("{} {value}", detail.heading),
            _ => detail.heading.clone(),
        },
        None => format!("{} detail", tab.label()),
    }
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn tabs_cycle_in_both_directions() {
        // Two tabs, so forward and backward are the same swap — the point of the
        // test is that neither direction can stick or panic.
        assert_eq!(LibraryTab::Views.next(), LibraryTab::Templates);
        assert_eq!(LibraryTab::Templates.next(), LibraryTab::Views);
        assert_eq!(LibraryTab::Views.prev(), LibraryTab::Templates);
        assert_eq!(LibraryTab::Templates.prev(), LibraryTab::Views);
        assert_eq!(LibraryTab::default(), LibraryTab::Views);
    }

    #[test]
    fn every_tab_has_a_label_section_noun_hint_and_empty_state() {
        for tab in LibraryTab::ALL {
            assert_ne!(tab.label(), "");
            assert_ne!(tab.section(), "");
            assert_ne!(tab.noun(), "");
            assert!(
                tab.hint().contains("Tab"),
                "{} hint names the tab key",
                tab.label()
            );
            assert!(
                tab.empty_hint().starts_with("No "),
                "{} empty state is a sentence: {}",
                tab.label(),
                tab.empty_hint()
            );
        }
        // The two tabs must not describe the same registry.
        assert_ne!(LibraryTab::Views.section(), LibraryTab::Templates.section());
    }

    #[test]
    fn the_cursor_clamps_to_the_row_count() {
        let mut state = LibraryState::default();
        assert!(state.move_selected(1, 3));
        assert_eq!(state.selected, 1);
        assert!(state.move_selected(5, 3), "clamped to the last row");
        assert_eq!(state.selected, 2);
        assert!(!state.move_selected(1, 3), "already at the last row");
        assert!(state.move_selected(-1, 3));
        assert_eq!(state.selected, 1);
        // An empty registry keeps the cursor at zero rather than past the end.
        state.move_selected(10, 0);
        assert_eq!(state.selected, 0);
        state.move_selected(-1, 0);
        assert_eq!(state.selected, 0);
    }

    #[test]
    fn a_shrinking_registry_clamps_the_cursor() {
        let mut state = LibraryState {
            tab: LibraryTab::Views,
            selected: 4,
        };
        state.clamp_selected(2);
        assert_eq!(state.selected, 1);
        state.clamp_selected(0);
        assert_eq!(state.selected, 0);
    }
}