Skip to main content

tix/ui/
tips.rs

1//! The startup tips modal (tix #1632).
2//!
3//! Shown once when a session reaches the TUI, over whatever was on screen — the
4//! board, or the create-issue form `tix new` opened. It shows one tip at a time
5//! from the embedded [`crate::tips`] list, starts on a random one so the same
6//! advice does not wear out, and walks the list with `Left`/`Right` (or `h`/`l`,
7//! or `p`/`n`), while `r` jumps to another random tip. `Enter`/`Esc` closes it.
8//!
9//! `d` turns the tips off for good: it writes `tips_on_startup = false` into
10//! `tix.toml` (through `App::persist_config`, the same writer the settings
11//! surface uses) and then shows one last line telling the reader how to bring the
12//! dialog back. The keys are **raw** — see `App::handle_key` — because the board
13//! binds several of the same letters and the binding layer would canonicalize
14//! them before the modal saw the key the user pressed.
15
16use ratatui::Frame;
17use ratatui::layout::{Constraint, Direction, Layout, Rect};
18use ratatui::style::{Modifier, Style};
19use ratatui::text::{Line, Span};
20use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
21
22use crate::tips::Tip;
23
24/// The keys the modal advertises while it is showing a tip.
25pub const TIPS_KEYS: &str = "h/p or ←: previous    l/n or →: next    r: random    Enter/Esc: close";
26
27/// The modal's own state, held by [`crate::app::App`] as `Some` exactly while
28/// [`crate::app::Mode::Tips`] is active.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TipsState {
31    /// Which tip is shown, as an index into [`crate::tips::all`].
32    pub index: usize,
33    /// Whether the user has just turned the tips off, so the modal is showing the
34    /// "you can turn them back on in `tix.toml`" notice instead of a tip.
35    pub disabled: bool,
36}
37
38impl TipsState {
39    /// The modal opened on `index`.
40    #[must_use]
41    pub fn new(index: usize) -> Self {
42        Self {
43            index,
44            disabled: false,
45        }
46    }
47
48    /// Move to the next tip, wrapping at the end.
49    pub fn next(&mut self, len: usize) {
50        if len > 0 {
51            self.index = (self.index + 1) % len;
52        }
53    }
54
55    /// Move to the previous tip, wrapping at the start.
56    pub fn previous(&mut self, len: usize) {
57        if len > 0 {
58            self.index = (self.index + len - 1) % len;
59        }
60    }
61
62    /// Jump to a random tip, landing on a **different** one whenever there is
63    /// more than one to show.
64    ///
65    /// The "different" part is the point of the key: pressing `r` to see another
66    /// tip and getting the one already on screen reads as a broken key, and with
67    /// a random start at open time there is nothing else drawing the seed — this
68    /// is the only place the modal asks for one.
69    pub fn randomize(&mut self, len: usize) {
70        if len <= 1 {
71            return;
72        }
73        let start = crate::tips::random_start(len);
74        self.index = if start == self.index {
75            (start + 1) % len
76        } else {
77            start
78        };
79    }
80}
81
82/// Draw the modal over `area`.
83pub fn render(f: &mut Frame, area: Rect, state: &TipsState, tips: &[Tip]) {
84    let rect = crate::ui::modal::modal_rect(area);
85    f.render_widget(Clear, rect);
86
87    let title = if state.disabled {
88        " Tix tips — disabled ".to_string()
89    } else {
90        let position = if tips.is_empty() {
91            String::new()
92        } else {
93            format!("  {}/{}", state.index + 1, tips.len())
94        };
95        format!(" Tix tips — did you know?{position} ")
96    };
97    let block = Block::default().borders(Borders::ALL).title(title);
98    let inner = block.inner(rect);
99    f.render_widget(block, rect);
100
101    // The body takes everything above a fixed footer, so the keys that close the
102    // modal stay on screen however long the tip is.
103    let rows = Layout::default()
104        .direction(Direction::Vertical)
105        .constraints([Constraint::Min(1), Constraint::Length(3)])
106        .split(inner);
107
108    let body: Vec<Line<'static>> = if state.disabled {
109        vec![
110            Line::from("Tips are now off.").style(Style::default().add_modifier(Modifier::BOLD)),
111            Line::from(""),
112            Line::from(
113                "You will not see this dialog again on startup. To bring it back, set \
114                 tips_on_startup = true in tix.toml.",
115            ),
116        ]
117    } else {
118        match tips.get(state.index) {
119            Some(tip) => vec![
120                Line::from(Span::styled(
121                    tip.category.clone(),
122                    Style::default().add_modifier(Modifier::BOLD),
123                )),
124                Line::from(""),
125                Line::from(tip.text.clone()),
126            ],
127            None => vec![Line::from("No tips are available.")],
128        }
129    };
130    f.render_widget(Paragraph::new(body).wrap(Wrap { trim: true }), rows[0]);
131
132    let footer: Vec<Line<'static>> = if state.disabled {
133        vec![Line::from("Enter/Esc: close")]
134    } else {
135        vec![
136            Line::from(TIPS_KEYS.to_string()),
137            Line::from(Span::styled(
138                "d: disable these tips on startup (written to tix.toml).",
139                Style::default().add_modifier(Modifier::DIM),
140            )),
141        ]
142    };
143    f.render_widget(Paragraph::new(footer).wrap(Wrap { trim: true }), rows[1]);
144}
145
146#[cfg(test)]
147mod tests {
148    #[allow(clippy::wildcard_imports)]
149    use super::*;
150
151    #[test]
152    fn next_and_previous_wrap() {
153        let mut state = TipsState::new(0);
154        state.previous(3);
155        assert_eq!(state.index, 2);
156        state.next(3);
157        assert_eq!(state.index, 0);
158    }
159
160    #[test]
161    fn navigation_over_an_empty_list_is_inert() {
162        let mut state = TipsState::new(0);
163        state.next(0);
164        state.previous(0);
165        assert_eq!(state.index, 0);
166    }
167
168    #[test]
169    fn randomize_lands_in_range_and_never_on_the_current_tip() {
170        for seed in 0..40 {
171            let mut state = TipsState::new(seed % 7);
172            for _ in 0..40 {
173                let before = state.index;
174                state.randomize(7);
175                assert!(state.index < 7, "index {} out of range", state.index);
176                assert_ne!(state.index, before, "the same tip was re-shown");
177            }
178        }
179    }
180
181    #[test]
182    fn randomize_over_a_single_tip_keeps_it() {
183        let mut state = TipsState::new(0);
184        state.randomize(1);
185        assert_eq!(state.index, 0);
186        state.randomize(0);
187        assert_eq!(state.index, 0);
188    }
189}