flashcards/src/main.rs

277 lines
6.1 KiB
Rust

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<Questions> = OnceLock::new();
fn get_questions() -> Questions {
QUESTIONS
.get_or_init(|| {
let args: Vec<String> = 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<Question> {
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<Question>, 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<bool>, is_wrong: Signal<bool>) -> 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" }
}
}
}