use crate::{ gui::util, model::{difficulty, difficulty::Difficulty, Card}, util::serialization, }; use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyModifiers}; use tui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span, Text}, widgets::{Paragraph, Wrap}, Terminal, }; struct State { pub input: String, pub answer: Answer, } enum Answer { Write, Difficulty { difficulty: Difficulty }, } pub enum Response { Aborted, Answered { difficulty: Difficulty }, } pub fn ask(terminal: &mut Terminal, title: &str, card: &Card) -> Result { let mut state = State { input: String::new(), answer: Answer::Write, }; loop { terminal.draw(|f| { let chunks = Layout::default() .direction(Direction::Vertical) .margin(2) .constraints( [ Constraint::Length(1), Constraint::Percentage(30), Constraint::Length(5), Constraint::Percentage(30), Constraint::Length(5), ] .as_ref(), ) .split(f.size()); let d1 = util::title(title); f.render_widget(d1, chunks[0]); let question = Paragraph::new(util::center_vertically(chunks[1], &card.question)) .alignment(Alignment::Center); f.render_widget(question, chunks[1]); let formatted_input = match state.answer { Answer::Write => format!("{}█", state.input), _ => format!("{} ", state.input), }; let answer = Paragraph::new(util::center_vertically(chunks[2], &formatted_input)) .style(match state.answer { Answer::Write => Style::default(), Answer::Difficulty { difficulty: _ } => { match check_response(&state.input, &card.responses) { CheckResponse::Correct { phonetics: _ } => { Style::default().fg(Color::Green) } CheckResponse::Incorrect => Style::default().fg(Color::Red), } } }) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); f.render_widget(answer, chunks[2]); if let Answer::Difficulty { difficulty: selected, } = state.answer { let maybe_indication: Option = match check_response(&state.input, &card.responses) { CheckResponse::Correct { phonetics } => phonetics, CheckResponse::Incorrect => { Some(serialization::words_to_line(&card.responses)) } }; if let Some(indication) = maybe_indication { let paragraph = Paragraph::new(util::center_vertically(chunks[3], &indication)) .alignment(Alignment::Center); f.render_widget(paragraph, chunks[3]); }; let difficulties = card.state.difficulties(); let l = difficulties.len(); let sep = Span::styled(" • ", Style::default()); let tabs = difficulties .iter() .enumerate() .map(|(i, d)| { let style = if *d == selected { Style::default() .fg(Color::Yellow) .add_modifier(Modifier::UNDERLINED) } else { Style::default().add_modifier(Modifier::DIM) }; let d = Span::styled(difficulty::label(*d), style); if i < l - 1 { [d, sep.clone()].to_vec() } else { [d].to_vec() } }) .collect::>>() .concat(); let p = Paragraph::new(Text::from(Line::from(tabs))).alignment(Alignment::Center); f.render_widget(p, chunks[4]); } })?; if let Event::Key(key) = event::read()? { match state.answer { Answer::Write => match key.code { KeyCode::Enter => { let difficulty = if state.input.is_empty() { // Encourage solving without typing by defaulting to good answer Difficulty::Good } else { match check_response(&state.input, &card.responses) { CheckResponse::Correct { phonetics: _ } => Difficulty::Good, CheckResponse::Incorrect => Difficulty::Again, } }; state.answer = Answer::Difficulty { difficulty } } KeyCode::Char(c) => { if key.modifiers.contains(KeyModifiers::CONTROL) { if c == 'u' { state.input.clear(); } else if c == 'w' { let mut words = state.input.split_whitespace().collect::>(); if !words.is_empty() { words.truncate(words.len() - 1); let joined_words = words.join(" "); let space = if !words.is_empty() { " " } else { "" }; state.input = format!("{joined_words}{space}"); } } else if c == 'c' { return Ok(Response::Aborted); } } else { state.input.push(c); if let CheckResponse::Correct { phonetics: _ } = check_response(&state.input, &card.responses) { state.answer = Answer::Difficulty { difficulty: Difficulty::Good, } } } } KeyCode::Backspace => { state.input.pop(); } _ => {} }, Answer::Difficulty { difficulty: selected, } => match key.code { KeyCode::Left => { if let Some(difficulty) = relative_element(&card.state.difficulties(), &selected, -1) { state.answer = Answer::Difficulty { difficulty } } } KeyCode::Right => { if let Some(difficulty) = relative_element(&card.state.difficulties(), &selected, 1) { state.answer = Answer::Difficulty { difficulty } } } KeyCode::Enter => { return Ok(Response::Answered { difficulty: selected, }) } KeyCode::Char('c') => { if key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(Response::Aborted); } } _ => {} }, } } } } enum CheckResponse { Incorrect, Correct { phonetics: Option }, } fn check_response(input: &str, responses: &[String]) -> CheckResponse { let input = remove_whitespaces(input); responses .iter() .find(|r| remove_indications_and_phonetics(r) == input) .map(|r| CheckResponse::Correct { phonetics: extract_phonetics(r), }) .unwrap_or(CheckResponse::Incorrect) } fn remove_whitespaces(input: &str) -> String { input .split_whitespace() .map(|word| word.trim()) .collect::>() .join(" ") } fn remove_indications_and_phonetics(response: &str) -> &str { response .split(|c| c == '(' || c == '[') .collect::>()[0] .trim() } fn extract_phonetics(response: &str) -> Option { let s1 = response.split('[').collect::>(); if s1.len() == 2 { let s2 = s1[1].split(']').collect::>(); if s2.len() > 1 { Some(format!("[{}]", s2[0])) } else { None } } else { None } } fn relative_element(xs: &[T], x: &T, ri: i32) -> Option { let i = xs.iter().position(|t| t == x)? as i32 + ri; if i >= 0 && i < xs.len() as i32 { Some(xs[i as usize].clone()) } else { None } }