tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The startup tips modal (tix #1632).
//!
//! Shown once when a session reaches the TUI, over whatever was on screen — the
//! board, or the create-issue form `tix new` opened. It shows one tip at a time
//! from the embedded [`crate::tips`] list, starts on a random one so the same
//! advice does not wear out, and walks the list with `Left`/`Right` (or `h`/`l`,
//! or `p`/`n`), while `r` jumps to another random tip. `Enter`/`Esc` closes it.
//!
//! `d` turns the tips off for good: it writes `tips_on_startup = false` into
//! `tix.toml` (through `App::persist_config`, the same writer the settings
//! surface uses) and then shows one last line telling the reader how to bring the
//! dialog back. The keys are **raw** — see `App::handle_key` — because the board
//! binds several of the same letters and the binding layer would canonicalize
//! them before the modal saw the key the user pressed.

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

use crate::tips::Tip;

/// The keys the modal advertises while it is showing a tip.
pub const TIPS_KEYS: &str = "h/p or ←: previous    l/n or →: next    r: random    Enter/Esc: close";

/// The modal's own state, held by [`crate::app::App`] as `Some` exactly while
/// [`crate::app::Mode::Tips`] is active.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TipsState {
    /// Which tip is shown, as an index into [`crate::tips::all`].
    pub index: usize,
    /// Whether the user has just turned the tips off, so the modal is showing the
    /// "you can turn them back on in `tix.toml`" notice instead of a tip.
    pub disabled: bool,
}

impl TipsState {
    /// The modal opened on `index`.
    #[must_use]
    pub fn new(index: usize) -> Self {
        Self {
            index,
            disabled: false,
        }
    }

    /// Move to the next tip, wrapping at the end.
    pub fn next(&mut self, len: usize) {
        if len > 0 {
            self.index = (self.index + 1) % len;
        }
    }

    /// Move to the previous tip, wrapping at the start.
    pub fn previous(&mut self, len: usize) {
        if len > 0 {
            self.index = (self.index + len - 1) % len;
        }
    }

    /// Jump to a random tip, landing on a **different** one whenever there is
    /// more than one to show.
    ///
    /// The "different" part is the point of the key: pressing `r` to see another
    /// tip and getting the one already on screen reads as a broken key, and with
    /// a random start at open time there is nothing else drawing the seed — this
    /// is the only place the modal asks for one.
    pub fn randomize(&mut self, len: usize) {
        if len <= 1 {
            return;
        }
        let start = crate::tips::random_start(len);
        self.index = if start == self.index {
            (start + 1) % len
        } else {
            start
        };
    }
}

/// Draw the modal over `area`.
pub fn render(f: &mut Frame, area: Rect, state: &TipsState, tips: &[Tip]) {
    let rect = crate::ui::modal::modal_rect(area);
    f.render_widget(Clear, rect);

    let title = if state.disabled {
        " Tix tips — disabled ".to_string()
    } else {
        let position = if tips.is_empty() {
            String::new()
        } else {
            format!("  {}/{}", state.index + 1, tips.len())
        };
        format!(" Tix tips — did you know?{position} ")
    };
    let block = Block::default().borders(Borders::ALL).title(title);
    let inner = block.inner(rect);
    f.render_widget(block, rect);

    // The body takes everything above a fixed footer, so the keys that close the
    // modal stay on screen however long the tip is.
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(3)])
        .split(inner);

    let body: Vec<Line<'static>> = if state.disabled {
        vec![
            Line::from("Tips are now off.").style(Style::default().add_modifier(Modifier::BOLD)),
            Line::from(""),
            Line::from(
                "You will not see this dialog again on startup. To bring it back, set \
                 tips_on_startup = true in tix.toml.",
            ),
        ]
    } else {
        match tips.get(state.index) {
            Some(tip) => vec![
                Line::from(Span::styled(
                    tip.category.clone(),
                    Style::default().add_modifier(Modifier::BOLD),
                )),
                Line::from(""),
                Line::from(tip.text.clone()),
            ],
            None => vec![Line::from("No tips are available.")],
        }
    };
    f.render_widget(Paragraph::new(body).wrap(Wrap { trim: true }), rows[0]);

    let footer: Vec<Line<'static>> = if state.disabled {
        vec![Line::from("Enter/Esc: close")]
    } else {
        vec![
            Line::from(TIPS_KEYS.to_string()),
            Line::from(Span::styled(
                "d: disable these tips on startup (written to tix.toml).",
                Style::default().add_modifier(Modifier::DIM),
            )),
        ]
    };
    f.render_widget(Paragraph::new(footer).wrap(Wrap { trim: true }), rows[1]);
}

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

    #[test]
    fn next_and_previous_wrap() {
        let mut state = TipsState::new(0);
        state.previous(3);
        assert_eq!(state.index, 2);
        state.next(3);
        assert_eq!(state.index, 0);
    }

    #[test]
    fn navigation_over_an_empty_list_is_inert() {
        let mut state = TipsState::new(0);
        state.next(0);
        state.previous(0);
        assert_eq!(state.index, 0);
    }

    #[test]
    fn randomize_lands_in_range_and_never_on_the_current_tip() {
        for seed in 0..40 {
            let mut state = TipsState::new(seed % 7);
            for _ in 0..40 {
                let before = state.index;
                state.randomize(7);
                assert!(state.index < 7, "index {} out of range", state.index);
                assert_ne!(state.index, before, "the same tip was re-shown");
            }
        }
    }

    #[test]
    fn randomize_over_a_single_tip_keeps_it() {
        let mut state = TipsState::new(0);
        state.randomize(1);
        assert_eq!(state.index, 0);
        state.randomize(0);
        assert_eq!(state.index, 0);
    }
}