1use 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
24pub const TIPS_KEYS: &str = "h/p or ←: previous l/n or →: next r: random Enter/Esc: close";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TipsState {
31 pub index: usize,
33 pub disabled: bool,
36}
37
38impl TipsState {
39 #[must_use]
41 pub fn new(index: usize) -> Self {
42 Self {
43 index,
44 disabled: false,
45 }
46 }
47
48 pub fn next(&mut self, len: usize) {
50 if len > 0 {
51 self.index = (self.index + 1) % len;
52 }
53 }
54
55 pub fn previous(&mut self, len: usize) {
57 if len > 0 {
58 self.index = (self.index + len - 1) % len;
59 }
60 }
61
62 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
82pub 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 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}