From a65a9591af1ce2bbdfe685196ca93b85c1c53bd7 Mon Sep 17 00:00:00 2001 From: Wynd Date: Sun, 15 Jun 2025 13:00:47 +0300 Subject: [PATCH] Added a nice animation for when correclty answering a question --- .editorconfig | 11 ++++ Cargo.lock | 2 + Cargo.toml | 2 + assets/main.css | 156 +++++++++++++++++++++++++++++++----------------- src/main.rs | 118 +++++++++++++++++------------------- src/models.rs | 39 ++++++++++++ 6 files changed, 210 insertions(+), 118 deletions(-) create mode 100644 .editorconfig create mode 100644 src/models.rs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4bde586 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = tab +indent_size = 4 +insert_final_newline = true + +[*.md] +max_line_length = off diff --git a/Cargo.lock b/Cargo.lock index b1a4e49..5409618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1405,8 +1405,10 @@ name = "flashcards" version = "0.1.0" dependencies = [ "dioxus", + "futures", "rand 0.9.1", "serde", + "tokio", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 459832f..f39af18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ dioxus = { version = "0.6.3", features = [] } toml = { version = "0.8" } serde = { version = "1.0", features = ["derive"] } rand = { version = "0.9" } +futures = { version = "0.3" } +tokio = { version = "1.45" } [features] default = ["desktop"] diff --git a/assets/main.css b/assets/main.css index 77871da..798d431 100644 --- a/assets/main.css +++ b/assets/main.css @@ -1,72 +1,118 @@ -/* App-wide styling */ +:root { + --correct: limegreen; + --correct-shadow: green; + --hover: gray; + --selection: goldenrod; +} + html { } body { - background-color: #0f1116; - color: #ffffff; - font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - margin: 20px; + background-color: #0f1116; + color: #ffffff; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + margin: 0px; +} + +#counter { + margin: 10px; + font-size: 20px; +} + +#result { + display: none; + position: absolute; + width: 100%; + font-size: 40vw; + margin: auto; + text-align: center; + line-height: 1; + color: var(--correct); + text-shadow: var(--correct-shadow) 1px 1px 30px; + opacity: 0; + + &.visible { + display: block; + animation: 200ms fade-in forwards; + } +} + +@keyframes fade-in { + 0% { + opacity: 0; + display: none; + } + 100% { + opacity: 1; + display: block; + } } form { - width: 100%; - text-align: center; - display: flex; - flex-direction: column; - font-size: 24px; - user-select: none; + width: 100%; + text-align: center; + display: flex; + flex-direction: column; + font-size: 24px; + user-select: none; - > div { - width: 100%; - height: 80px; - display: flex; - margin-top: 10px; - align-items: center; - align-self: center; - box-sizing: border-box; - border: solid 2px unset; + h1 { + &.success { + color: var(--correct); + } + } - &.selected { - border: solid 2px goldenrod; - } + > div { + width: 100%; + height: 80px; + display: flex; + margin-top: 10px; + align-items: center; + align-self: center; + box-sizing: border-box; + border: solid 2px unset; - &:hover:not(.selected) { - border: solid 1px gray; - } + &.selected { + border: solid 2px var(--selection); + } - > input[type="checkbox"] { - display: none; - scale: 1.5; - margin-right: 10px; - } + &:hover:not(.selected) { + border: solid 1px var(--hoveer); + } - > label { - flex-grow: 1; - } + > input[type="checkbox"] { + display: none; + scale: 1.5; + margin-right: 10px; + } - &:hover, - > *:hover { - cursor: pointer; - } - } + > label { + flex-grow: 1; + } - > input[type="submit"] { - margin-top: 40px; - height: 70px; - background-color: transparent; - border: none; - color: white; - font-size: 42px; + &:hover, + > *:hover { + cursor: pointer; + } + } - &:hover { - font-size: 48px; - color: goldenrod; - cursor: pointer; - } + > input[type="submit"] { + margin-top: 40px; + height: 70px; + background-color: transparent; + border: none; + color: white; + font-size: 42px; - &:focus { - outline: none; - } - } + &:hover { + font-size: 48px; + color: var(--selection); + cursor: pointer; + } + + &:focus { + outline: none; + } + } } diff --git a/src/main.rs b/src/main.rs index 462765a..4914cc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,59 +1,16 @@ -use std::{ - ops::{Deref, DerefMut}, - sync::LazyLock, -}; +use std::{sync::LazyLock, time::Duration}; use dioxus::{ - desktop::{Config, WindowBuilder}, + desktop::{Config, LogicalSize, WindowBuilder}, prelude::*, }; +use futures::StreamExt; +use models::{Question, Questions}; use rand::seq::SliceRandom; -use serde::Deserialize; -// const FAVICON: Asset = asset!("/assets/favicon.ico"); +mod models; + const MAIN_CSS: Asset = asset!("/assets/main.css"); -// const HEADER_SVG: Asset = asset!("/assets/header.svg"); - -#[derive(Debug, Deserialize)] -pub struct Questions { - questions: Vec, -} - -impl Deref for Questions { - type Target = Vec; - - fn deref(&self) -> &Self::Target { - &self.questions - } -} - -impl DerefMut for Questions { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.questions - } -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Question { - message: String, - answers: Vec, -} - -#[derive(Debug, Clone, PartialEq, Deserialize)] -pub struct Answer { - message: String, - - #[serde(default)] - is_correct: Option, - - #[serde(default, skip)] - checked: bool, -} - -#[derive(Debug, Props, PartialEq, Clone)] -struct QuestionProps { - current: Question, -} pub static QUESTIONS: LazyLock = LazyLock::new(|| { let questions_str = std::fs::read_to_string("questions.toml").unwrap(); @@ -81,6 +38,7 @@ fn main() { .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"), ), @@ -91,14 +49,13 @@ fn main() { #[component] fn App() -> Element { rsx! { - // document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } - QuestionPrompt {} + QuestionForm {} } } #[component] -pub fn QuestionPrompt() -> Element { +pub fn QuestionForm() -> Element { let mut questions = use_signal(get_questions); let mut current = use_signal(move || questions.remove(0)); @@ -123,6 +80,25 @@ pub fn QuestionPrompt() -> Element { let current_question = use_memo(move || questions().len()); let left_questions = use_memo(move || total_questions - current_question()); + let mut success_animation = use_signal(|| false); + + use_future(move || async move { + loop { + tokio::time::sleep(Duration::from_millis(400)).await; + if success_animation() { + tokio::time::sleep(Duration::from_millis(600)).await; + success_animation.set(false); + current().answers.iter_mut().for_each(|a| a.checked = false); + if !questions().is_empty() { + current.set(questions.remove(0)); + } else { + questions.set(get_questions()); + current.set(questions.remove(0)); + } + } + } + }); + let answer_buttons = current() .answers .into_iter() @@ -134,23 +110,26 @@ pub fn QuestionPrompt() -> Element { }); rsx! { - div { "{left_questions}/{total_questions}" }, + ResultPopup { is_visible: success_animation }, + div { + id: "counter", + "{left_questions}/{total_questions}" + }, form { id: "form", onsubmit: move |_| { if actual_correct() == total_correct() { - current().answers.iter_mut().for_each(|a| a.checked = false); - if !questions().is_empty() { - current.set(questions.remove(0)); - } - else { - questions.set(get_questions()); - current.set(questions.remove(0)); - } + success_animation.set(true); + } + else { + } }, - h1 { "{current().message}" } + h1 { + class: if success_animation() { "success" }, + "{current().message}" + } { answer_buttons } input { type: "submit", @@ -181,7 +160,7 @@ pub fn AnswerCheckbox(current: Signal, id: usize) -> Element { rsx! { div { - class: if checked() { "selected" } else { "" }, + class: if checked() { "selected" }, onclick: move |_| { let mut write_lock = current.write(); if let Some(answer) = write_lock.answers.get_mut(id) { @@ -205,3 +184,16 @@ pub fn AnswerCheckbox(current: Signal, id: usize) -> Element { } } } + +#[component] +pub fn ResultPopup(is_visible: Signal) -> Element { + let is_visible = use_memo(move || is_visible()); + + rsx! { + div { + id: "result", + class: if is_visible() { "visible" }, + "✓" + } + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..86fda1f --- /dev/null +++ b/src/models.rs @@ -0,0 +1,39 @@ +use std::ops::{Deref, DerefMut}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Questions { + pub questions: Vec, +} + +impl Deref for Questions { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.questions + } +} + +impl DerefMut for Questions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.questions + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Question { + pub message: String, + pub answers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct Answer { + pub message: String, + + #[serde(default)] + pub is_correct: Option, + + #[serde(default, skip)] + pub checked: bool, +}