use std::{env, sync::OnceLock, time::Duration}; use dioxus::{ desktop::{Config, LogicalSize, WindowBuilder}, prelude::*, }; use models::{Question, Questions}; use rand::seq::SliceRandom; mod models; const DEFAULT_FILE: &str = "questions.toml"; const MAIN_CSS: Asset = asset!("/assets/main.css"); pub static QUESTIONS: OnceLock = OnceLock::new(); fn get_questions() -> Questions { QUESTIONS .get_or_init(|| { let args: Vec = env::args().collect(); let file_name = args.get(1).map(|x| x.as_str()).unwrap_or(DEFAULT_FILE); let questions_str = std::fs::read_to_string(file_name) .unwrap_or_else(|_| panic!("Could not read from file {file_name}")); let mut questions: Questions = toml::from_str(&questions_str).expect("Could not decode the given file as TOML"); if args.len() > 2 { for i in 2..args.len() { let file_name = args.get(i).map(|x| x.as_str()); let Some(file_name) = file_name else { continue; }; let Ok(questions_str) = std::fs::read_to_string(file_name) else { continue; }; let Ok(other_questions) = toml::from_str(&questions_str) else { continue; }; questions += other_questions; } } questions }) .clone() } fn get_rand_questions() -> Vec { let mut questions = get_questions().questions; let mut rng = rand::rng(); // Randomize the answers questions .iter_mut() .for_each(|q| q.answers.shuffle(&mut rng)); // Randomize the questions list questions.shuffle(&mut rng); questions } fn main() { dioxus::LaunchBuilder::new() .with_cfg( Config::default().with_menu(None).with_window( WindowBuilder::new() .with_min_inner_size(LogicalSize::new(640, 540)) .with_maximized(true) .with_title("Flashcards"), ), ) .launch(App) } #[component] fn App() -> Element { rsx! { document::Link { rel: "stylesheet", href: MAIN_CSS } QuestionForm {} } } #[component] pub fn QuestionForm() -> Element { let mut questions = use_signal(get_rand_questions); let mut current = use_signal(move || questions.remove(0)); let total_correct = use_memo(move || { current .read() .answers .iter() .filter(|a| a.is_correct.unwrap_or_default()) .count() }); let actual_correct = use_memo(move || { let current_lock = current.read(); let incorrectly_checked = current_lock .answers .iter() .filter(|a| !a.is_correct.unwrap_or_default() && a.checked) .count(); let correctly_checked = current_lock .answers .iter() .filter(|a| a.is_correct.unwrap_or_default() && a.checked) .count(); correctly_checked.saturating_sub(incorrectly_checked) }); let total_questions = get_questions().len(); let current_question = use_memo(move || questions().len()); let left_questions = use_memo(move || total_questions - current_question()); let mut correct_questions = use_signal(|| 0); let mut wrong_questions = use_signal(|| 0); let mut correct_animation = use_signal(|| false); let mut wrong_animation = use_signal(|| false); use_future(move || async move { loop { tokio::time::sleep(Duration::from_millis(400)).await; let is_correct = correct_animation(); let is_wrong = wrong_animation(); if is_correct || is_wrong { tokio::time::sleep(Duration::from_millis(600)).await; // Uncheck all the answers current().answers.iter_mut().for_each(|a| a.checked = false); let mut correct_questions_lock = correct_questions.write(); let mut wrong_questions_lock = wrong_questions.write(); // Get a new question or reroll the list if !questions().is_empty() { current.set(questions.remove(0)); // Update the correct/wrong counters if is_correct { *correct_questions_lock += 1; } else if is_wrong { *wrong_questions_lock += 1; } } else { questions.set(get_rand_questions()); current.set(questions.remove(0)); *correct_questions_lock = 0; *wrong_questions_lock = 0; } // Reset animation flags correct_animation.set(false); wrong_animation.set(false); } } }); let answer_buttons = current() .answers .into_iter() .enumerate() .map(|(i, _answer)| { rsx! { AnswerCheckbox { current, id: i } } }); rsx! { ResultPopup { is_correct: correct_animation, is_wrong: wrong_animation }, div { id: "counter", div { "{left_questions}" } div { class: "correct", "{correct_questions}" } div { class: "wrong", "{wrong_questions}" } div { "{total_questions}" } }, form { id: "form", onsubmit: move |_| { if actual_correct() == total_correct() { correct_animation.set(true); } else { wrong_animation.set(true); } }, h1 { class: if correct_animation() { "correct" }, class: if wrong_animation() { "wrong" }, "{current().message}" } { answer_buttons } input { type: "submit", } } } } #[component] pub fn AnswerCheckbox(current: Signal, id: usize) -> Element { let message = use_memo(move || { current .read() .answers .get(id) .map(|a| a.message.clone()) .unwrap_or("???".to_string()) }); let checked = use_memo(move || { current .read() .answers .get(id) .map(|a| a.checked) .unwrap_or(false) }); rsx! { div { class: if checked() { "selected" }, onclick: move |_| { let mut write_lock = current.write(); if let Some(answer) = write_lock.answers.get_mut(id) { answer.checked = !answer.checked; } }, input { id: "a{id}", name: "a{id}", type: "checkbox", value: "{id}", checked: "{checked}", onclick: move |event| event.prevent_default(), } label { for: "a{id}", onclick: move |event| event.prevent_default(), "{message}" }, } } } #[component] pub fn ResultPopup(is_correct: Signal, is_wrong: Signal) -> Element { let is_correct = use_memo(move || is_correct()); let is_wrong = use_memo(move || is_wrong()); let is_visible = use_memo(move || is_correct() || is_wrong()); rsx! { div { id: "result", class: if is_visible() { "visible" }, class: if is_correct() { "correct" }, class: if is_wrong() { "wrong" }, if is_correct() { "✓" } else { "X" } } } }