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;
pub const TIPS_KEYS: &str = "h/p or ←: previous l/n or →: next r: random Enter/Esc: close";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TipsState {
pub index: usize,
pub disabled: bool,
}
impl TipsState {
#[must_use]
pub fn new(index: usize) -> Self {
Self {
index,
disabled: false,
}
}
pub fn next(&mut self, len: usize) {
if len > 0 {
self.index = (self.index + 1) % len;
}
}
pub fn previous(&mut self, len: usize) {
if len > 0 {
self.index = (self.index + len - 1) % len;
}
}
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
};
}
}
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);
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);
}
}