use anyhow::anyhow;
use ratatui::{
Frame,
crossterm::event,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::Text,
widgets::{
Block, BorderType, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState,
},
};
use ratatui_textarea::{Input, Key, TextArea};
use pickems::datatypes::{Map, Name, Rating, Seed, Team};
const BROWSE_FOOTER: [&str; 2] = [
"(Esc) quit | (Ctrl + S) save | (Ctrl + N) add team | (Del) remove team",
"(↑) up | (↓) down | (←) left | (→) right | (Enter) edit field",
];
const EDITOR_FOOTER: &str = "(Esc) cancel edit | (Enter) commit edit";
pub struct Wizard<'a> {
save: bool,
cancel: bool,
teams: Vec<(Name, Team)>,
problems: Vec<String>,
state: TableState,
editor: Option<TextArea<'a>>,
}
impl Wizard<'_> {
pub fn run() -> anyhow::Result<Option<Map>> {
let mut wizard = Self {
save: false,
cancel: false,
teams: Vec::with_capacity(16),
problems: Vec::with_capacity(4),
state: TableState::default().with_selected(0),
editor: None,
};
for _ in 0..16 {
wizard.add_new_team();
}
let mut terminal = ratatui::init();
while !(wizard.cancel || wizard.save) {
if let Err(e) = {
terminal.draw(|frame| wizard.render(frame))?;
wizard.handle_crossterm_events()?;
Ok(())
} {
ratatui::restore();
return Err(e);
}
}
ratatui::restore();
if wizard.save {
Ok(Some(Map::from(wizard.teams)))
} else {
Ok(None)
}
}
fn handle_crossterm_events(&mut self) -> anyhow::Result<()> {
if let Some(editor) = self.editor.as_mut() {
match event::read()?.into() {
Input {
key: Key::Char('c' | 'C'),
ctrl: true,
..
} => self.cancel = true,
Input { key: Key::Esc, .. } => self.editor = None,
Input {
key: Key::Enter, ..
} => self.commit_edit(),
input => {
if editor.input(input) {
self.validate_editor();
}
}
}
} else {
match event::read()?.into() {
Input { key: Key::Esc, .. }
| Input {
key: Key::Char('c' | 'C'),
ctrl: true,
..
} => self.cancel = true,
Input {
key: Key::Char('s' | 'S'),
ctrl: true,
..
} if self.problems.is_empty() => {
self.save = true;
}
Input {
key: Key::Char('n' | 'N'),
ctrl: true,
..
} => self.add_new_team(),
Input {
key: Key::Delete, ..
} => self.remove_selected_team(),
Input {
key: Key::Enter, ..
} => self.start_edit(),
Input {
key: Key::Down | Key::Char('j'),
..
} => self.next_row(),
Input {
key: Key::Up | Key::Char('k'),
..
} => self.previous_row(),
Input {
key: Key::Right | Key::Char('l'),
..
} => self.next_column(),
Input {
key: Key::Left | Key::Char('h'),
..
} => self.previous_column(),
_ => {}
}
}
Ok(())
}
fn parse_edit(&self) -> anyhow::Result<(Name, Team)> {
if let Some((row, col)) = self.state.selected_cell() {
let (mut name, mut data) = self.teams[row].clone();
if let Some(editor) = &self.editor {
match col {
0 => {
let seed = Seed::try_new(editor.lines()[0].parse::<u16>()?)?;
data.seed = seed;
Ok((name, data))
}
1 => {
let new_name = Name::try_new(&editor.lines()[0])?;
if self.teams.iter().any(|(name, _)| name == &new_name) {
Err(anyhow!("name already exists, must be unique"))
} else {
name.clone_from(&new_name);
Ok((name, data))
}
}
2 => {
data.rating = Rating::try_new(editor.lines()[0].parse::<u16>()?)?;
Ok((name, data))
}
_ => unreachable!(),
}
} else {
Err(anyhow!("failed to access editor (cancel edit and retry)"))
}
} else {
Err(anyhow!("failed to select team (cancel edit and retry)"))
}
}
fn validate_editor(&mut self) -> bool {
let parse = self.parse_edit();
self.editor.as_mut().is_some_and(|editor| {
if let Err(err) = parse {
editor.set_block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Color::LightRed)
.title(format!("ERROR: {err}")),
);
false
} else {
editor.set_block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Color::LightGreen)
.title("OK"),
);
true
}
})
}
fn start_edit(&mut self) {
if let Some((_, col)) = self.state.selected_cell() {
let mut textarea = TextArea::default();
let placeholder = match col {
0 => "Enter a valid seed (1-16)",
1 => "Enter the team name",
2 => "Enter the global ranking points for the team",
_ => unreachable!(),
};
textarea.set_placeholder_text(placeholder);
self.editor = Some(textarea);
self.validate_editor();
}
}
fn commit_edit(&mut self) {
if let Ok(team) = self.parse_edit() {
if let Some(index) = self.state.selected() {
self.teams[index] = team;
} else {
self.teams.push(team);
}
self.on_teams_change();
self.editor = None;
}
}
fn add_new_team(&mut self) {
let seeds = self
.teams
.iter()
.map(|(_, data)| data.seed)
.collect::<Vec<_>>();
let Some(seed) = Seed::iter_all().find(|n| !seeds.contains(n)) else {
return;
};
let name = Name::try_new(format!("Team {seed}")).unwrap();
let rating = Rating::try_new(1000).unwrap();
self.teams.push((name, Team { seed, rating }));
self.on_teams_change();
self.state.select(
self.teams
.iter()
.enumerate()
.find_map(|(i, (_, data))| if data.seed == seed { Some(i) } else { None }),
);
}
fn remove_selected_team(&mut self) {
if let Some(i) = self.state.selected() {
self.teams.remove(i);
if i >= self.teams.len() {
self.state.select(self.teams.len().checked_sub(1));
}
}
self.on_teams_change();
}
fn on_teams_change(&mut self) {
self.teams.sort_by_key(|(_, a)| a.seed);
self.problems.clear();
if self.teams.len() < 16 {
self.problems
.push(format!("Not enough teams ({}/16)", self.teams.len()));
}
let seeds = self
.teams
.iter()
.map(|(_, data)| data.seed)
.collect::<Vec<_>>();
for seed in &seeds {
if !Seed::iter_all().any(|valid_seed| valid_seed == *seed) {
self.problems.push(format!("Invalid seed ({seed})"));
}
}
for i in Seed::iter_all() {
if seeds.iter().filter(|&&seed| seed == i).count() > 1 {
self.problems.push(format!("Duplicate seed ({i})"));
}
}
for (i, (name, _)) in self.teams.iter().enumerate() {
if self
.teams
.iter()
.skip(i + 1)
.any(|(other_name, _)| name == other_name)
{
self.problems.push(format!("Duplicate name ({name})"));
}
}
}
const fn next_row(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.teams.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
const fn previous_row(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.teams.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn next_column(&mut self) {
self.state.select_next_column();
}
fn previous_column(&mut self) {
self.state.select_previous_column();
}
#[allow(clippy::cast_possible_truncation)]
fn render(&mut self, frame: &mut Frame) {
let rects = Layout::vertical([
Constraint::Min(17),
Constraint::Length(if self.problems.is_empty() {
1
} else {
self.problems.len() as u16 + 2
}),
Constraint::Length(if self.editor.is_some() { 3 } else { 0 }),
Constraint::Length(if self.editor.is_some() { 3 } else { 4 }),
])
.split(frame.area());
self.render_table(frame, rects[0]);
self.render_problems(frame, rects[1]);
self.render_editor(frame, rects[2]);
self.render_footer(frame, rects[3]);
}
fn render_table(&mut self, frame: &mut Frame, area: Rect) {
let header_style = Style::default();
let selected_row_style = if self.editor.is_some() {
Style::default()
} else {
Style::default().add_modifier(Modifier::REVERSED)
};
let selected_cell_style = if self.editor.is_some() {
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED)
.add_modifier(Modifier::REVERSED)
} else {
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED)
};
let header = ["Seed", "Team", "Rating"]
.into_iter()
.map(Cell::from)
.collect::<Row>()
.style(header_style)
.height(1);
let rows = self.teams.iter().map(|(name, data)| {
let item = [
format!(" {}.", data.seed),
name.to_string(),
format!("{}", data.rating),
];
item.into_iter()
.map(|content| Cell::from(Text::from(content)))
.collect::<Row>()
.height(1)
});
let t = Table::new(
rows,
[
Constraint::Length(7),
Constraint::Min(20),
Constraint::Min(8),
],
)
.header(header)
.row_highlight_style(selected_row_style)
.cell_highlight_style(selected_cell_style)
.highlight_spacing(HighlightSpacing::Always);
frame.render_stateful_widget(t, area, &mut self.state);
}
fn render_problems(&self, frame: &mut Frame, area: Rect) {
let problems = if self.problems.is_empty() {
Paragraph::new(Text::from("No problems")).light_green()
} else {
Paragraph::new(self.problems.iter().map(String::as_str).collect::<Text>()).block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Color::LightRed)
.title("Problems"),
)
};
frame.render_widget(problems, area);
}
fn render_editor(&self, frame: &mut Frame, area: Rect) {
if let Some(editor) = &self.editor {
frame.render_widget(editor, area);
}
}
fn render_footer(&self, frame: &mut Frame, area: Rect) {
let footer = if self.editor.is_some() {
Paragraph::new(Text::from(EDITOR_FOOTER))
.centered()
.block(Block::bordered().border_type(BorderType::Rounded))
} else {
Paragraph::new(Text::from_iter(BROWSE_FOOTER))
.centered()
.block(Block::bordered().border_type(BorderType::Rounded))
};
frame.render_widget(footer, area);
}
}