From 6268b0ab33110ebab9bcd0171a2cdf8be995d01d Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Sun, 22 Feb 2026 11:07:57 +0300 Subject: [PATCH] Add dictionary and dictionary quiz pages --- Cargo.lock | 58 ++++++++ Cargo.toml | 3 + src/dictionary.rs | 317 +++++++++++++++++++++++++++++++++++++++++ src/dictionary_test.rs | 148 +++++++++++++++++++ src/main.rs | 21 ++- src/quiz.rs | 15 +- src/selector.rs | 26 ++-- src/writing.rs | 7 +- 8 files changed, 568 insertions(+), 27 deletions(-) create mode 100644 src/dictionary.rs create mode 100644 src/dictionary_test.rs diff --git a/Cargo.lock b/Cargo.lock index 11d89da..4002747 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -649,6 +649,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -986,6 +1007,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1392,8 +1424,11 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" name = "jap_learn" version = "0.1.0" dependencies = [ + "dirs", "iced", "rand", + "serde", + "serde_json", ] [[package]] @@ -2146,6 +2181,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.50" @@ -2440,6 +2481,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -3070,6 +3122,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" diff --git a/Cargo.toml b/Cargo.toml index 22c0ba9..ea96505 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,6 @@ edition = "2024" [dependencies] iced = { version = "0.14.0", features = ["canvas"] } rand = "0.10.0" +dirs = "6.0.0" +serde = { version = "1.0.144", features = ["derive"] } +serde_json = "1.0.149" \ No newline at end of file diff --git a/src/dictionary.rs b/src/dictionary.rs new file mode 100644 index 0000000..2d59cc0 --- /dev/null +++ b/src/dictionary.rs @@ -0,0 +1,317 @@ +use crate::dictionary::DictionaryMessage::Test; +use crate::dictionary_test::DictionaryQuizState; +use crate::{NavigatedPage, Page, RootMessage}; +use iced::alignment::Vertical::Center; +use iced::widget::button::Style; +use iced::widget::*; +use iced::{Border, Color, Length, Shadow, Task}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::fs::File; +use std::io::Read; +use std::path::PathBuf; +use DictionaryMessage::Back; + +#[derive(Clone, Debug)] +pub struct DictionaryState { + dict: Vec, + include_map: Vec, + tag_map: HashMap, + reverse: bool +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DictionaryElement { + pub key: String, + pub value: String, + pub tags: String, +} + +impl DictionaryElement { + fn new() -> Self { + Self { + key: String::new(), + value: String::new(), + tags: String::new(), + } + } +} + +#[derive(Debug, Clone)] +pub enum DictionaryMessage { + Back, + SetTags(usize, String), + SetKey(usize, String), + SetValue(usize, String), + Remove(usize), + NewWord, + Include(usize, bool), + IncludeTag(String, bool), + Save, + Test, + ResetTags, + SetReverse(bool), +} + +impl NavigatedPage for DictionaryState { + fn navigate(&self, message: &DictionaryMessage) -> Option { + if let Back = message { + return Some(Page::PreviousPage); + } + if let Test = message { + if self.include_map.iter().any(|x| *x) { + let mut words = vec![]; + for i in 0..self.include_map.len(){ + if self.include_map[i] { + words.push(self.dict[i].clone()); + } + } + return Some(Page::DictionaryQuiz(DictionaryQuizState::new(words, self.reverse))) + } + } + None + } +} + +impl Default for DictionaryState { + fn default() -> Self { + Self::new() + } +} +impl DictionaryState { + pub fn new() -> Self { + let mut current_dict = "[]".to_string(); + match File::open(dict_file()) { + Ok(mut f) => { + current_dict = String::new(); + f.read_to_string(&mut current_dict).unwrap(); + } + _ => {} + } + let list: Vec = serde_json::from_str(¤t_dict).unwrap(); + let mut result = DictionaryState { + include_map: vec![false; list.len()], + dict: list, + tag_map: HashMap::new(), + reverse: false, + }; + + result.update_tags(); + result + } + + pub fn update(&mut self, message: DictionaryMessage) -> Task { + match message { + DictionaryMessage::NewWord => { + self.dict.push(DictionaryElement::new()); + self.include_map.push(false); + } + DictionaryMessage::SetKey(i, v) => self.dict[i].key = v, + DictionaryMessage::SetValue(i, v) => self.dict[i].value = v, + DictionaryMessage::SetTags(i, v) => { + self.dict[i].tags = v; + self.update_tags(); + } + DictionaryMessage::Remove(i) => { + self.dict.remove(i); + } + DictionaryMessage::Include(i, b) => self.include_map[i] = b, + DictionaryMessage::IncludeTag(t, v) => { + println!("Including tag {}", t); + self.tag_map.insert(t, v); + self.update_words_include() + } + DictionaryMessage::ResetTags => { + self.tag_map.iter_mut().for_each(|(_, v)| *v = false); + self.include_map.iter_mut().for_each(|x| *x = false) + } + DictionaryMessage::Save => { + let dir = dict_file(); + let content = serde_json::to_string_pretty(&self.dict.clone()).unwrap(); + fs::write(dir, content.clone()) + .unwrap_or_else(|e| println!("Can't write file: {}", e)); + println!("{}", content); + }, + DictionaryMessage::SetReverse(v) => self.reverse = v, + _ => {} + } + + Task::none() + } + + pub fn view(&self) -> iced::Element<'_, DictionaryMessage> { + container(row![ + iced::widget::column![ + button("Назад").on_press(Back), + self.words_list(), + row![ + button("Добавить слово").on_press(DictionaryMessage::NewWord), + button("Сохранить словарь").on_press(DictionaryMessage::Save), + ] + .spacing(10) + ] + .spacing(5) + .padding(10), + self.filters() + ]) + .padding(10) + .into() + } + + fn words_list(&self) -> iced::Element<'_, DictionaryMessage> { + let mut col = Column::new().width(Length::Fill); + + let mut i = 0; + for word in &self.dict { + let mut line = Row::new().width(Length::Fill).align_y(Center); + line = line + .push( + checkbox(self.include_map[i]) + .on_toggle(move |b| DictionaryMessage::Include(i, b)), + ) + .push(space().width(10)); + + line = line.push( + text_input("Ключ", &word.key).size(20) + .width(Length::Fill) + .on_input(move |string| DictionaryMessage::SetKey(i, string)), + ); + line = line.push( + text_input("Значение", &word.value).size(20) + .width(Length::Fill) + .on_input(move |string| DictionaryMessage::SetValue(i, string)), + ); + line = line.push( + text_input("Тэги", &word.tags).size(20) + .width(Length::Fill) + .on_input(move |string| DictionaryMessage::SetTags(i, string)), + ); + + line = line + .push( + button("-") + .on_press_with(move || DictionaryMessage::Remove(i)) + .style(|_x, _status| Style { + background: None, + text_color: Color::BLACK, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }), + ) + .push(space().width(10)); + + col = col.push(line); + i += 1; + } + + scrollable(col).height(Length::Fill).into() + } + + fn filters(&self) -> iced::Element<'_, DictionaryMessage> { + iced::widget::column![ + text!("Всего слов: {}", self.dict.len()), + text!( + "Выбрано слов: {}", + self.include_map.iter().filter(|i| **i).count() + ), + self.tags_selector(), + toggler(self.reverse).label("Обратный тест").on_toggle(DictionaryMessage::SetReverse), + button(text!("Тест").center().width(Length::Fill)) + .on_press(Test) + .width(Length::Fill), + ] + .width(250) + .spacing(10) + .into() + } + + fn tags_selector(&self) -> iced::Element<'_, DictionaryMessage> { + let mut col = Column::new().width(Length::Fill); + col = col.push( + button("Сбросить") + .on_press(DictionaryMessage::ResetTags) + .style(|x: &Theme, _status| Style { + background: None, + text_color: x.palette().primary, + border: Border::default(), + shadow: Shadow::default(), + snap: false, + }), + ); + for tag in &self.tag_map { + col = col.push( + checkbox(*tag.1) + .label(tag.0) + .on_toggle(|x1| DictionaryMessage::IncludeTag(tag.0.clone(), x1)), + ) + } + + container(scrollable(col)).height(Length::Fill).into() + } + + fn update_tags(&mut self) { + let mut tags_list: Vec = vec![]; + for element in &self.dict { + tags_list.append(&mut to_tags_list(element.tags.clone())); + } + + let current_tags = self + .tag_map + .keys() + .map(|k| k.clone().to_string()) + .collect::>(); + for current in current_tags { + if !tags_list.contains(¤t) { + self.tag_map.remove(¤t.clone()); + } + } + + for found_tag in &tags_list { + if !self.tag_map.contains_key(&found_tag.clone()) { + self.tag_map.insert(found_tag.clone(), false); + } + } + } + + fn update_words_include(&mut self) { + let include_tags = self + .tag_map + .iter() + .filter(|i| *(*i).1) + .map(|(t, _)| t.clone()) + .collect::>(); + + println!("Including include tags: {:?}", include_tags); + + if include_tags.is_empty() { + self.include_map.iter_mut().for_each(|x| *x = false); + return; + } + + for i in 0..self.include_map.len() { + let tags = to_tags_list(self.dict[i].tags.clone()); + if tags.iter().any(|t| include_tags.contains(t)) { + self.include_map[i] = true; + } + } + } +} + +fn to_tags_list(ts: String) -> Vec { + ts.split(',') + .map(|ts| ts.to_lowercase().trim().to_string()) + .filter(|t| !t.is_empty()) + .collect::>() +} + +fn dict_file() -> PathBuf { + let mut dir = dirs::data_dir().unwrap(); + dir.push("jap_learn"); + if !dir.exists() { + fs::create_dir(dir.clone()).unwrap(); + } + dir.push("dict.json"); + dir +} diff --git a/src/dictionary_test.rs b/src/dictionary_test.rs new file mode 100644 index 0000000..25471fd --- /dev/null +++ b/src/dictionary_test.rs @@ -0,0 +1,148 @@ +use crate::dictionary::DictionaryElement; +use crate::quiz::Score; +use crate::Page::PreviousPage; +use crate::RootMessage; +use crate::{NavigatedPage, Page}; +use iced::widget::{button, container, row, text, text_input}; +use iced::{alignment, Fill, Task}; +use rand::prelude::SliceRandom; + +#[derive(Debug, Clone)] +pub struct DictionaryQuizState { + words: Vec, + current_set: Vec, + answer: String, + view: String, + correct: String, + score: Score, + is_help: bool, + reverse: bool, +} +#[derive(Debug, Clone)] +pub enum DictionaryQuizMessage { + Next, + Back, + AnswerChanged(String), + SubmitAnswer, +} + +impl NavigatedPage for DictionaryQuizState { + fn navigate(&self, message: &DictionaryQuizMessage) -> Option { + match message { + DictionaryQuizMessage::Back => Some(PreviousPage), + _ => None, + } + } +} + +impl DictionaryQuizState { + pub fn new(words: Vec, reverse: bool) -> DictionaryQuizState { + DictionaryQuizState { + words, + current_set: Vec::new(), + answer: "".to_string(), + view: "---".to_string(), + correct: "".to_string(), + score: Default::default(), + is_help: false, + reverse: reverse, + } + } + + pub fn update(&mut self, message: DictionaryQuizMessage) -> Task { + match message { + DictionaryQuizMessage::Next => { + self.next(); + } + DictionaryQuizMessage::Back => {} + DictionaryQuizMessage::AnswerChanged(c) => self.answer = c.clone(), + DictionaryQuizMessage::SubmitAnswer => self.submit(), + } + Task::none() + } + + pub fn view(&self) -> iced::Element<'_, DictionaryQuizMessage> { + container( + iced::widget::column![ + row![ + text!("{}", self.view).size(54), + text!( + "{}", + if self.is_help { + self.correct.clone() + } else { + String::new() + } + ), + ] + .align_y(alignment::Vertical::Center) + .spacing(20), + text_input("Перевод", &self.answer) + .size(28) + .width(150) + .on_input(DictionaryQuizMessage::AnswerChanged) + .on_submit(DictionaryQuizMessage::SubmitAnswer), + row![ + text!("{}", self.score.total.to_string()).size(25), + text!("{}", self.score.correct.to_string()) + .size(25) + .color(iced::Color::from_rgb8(60, 170, 60)), + text!("{}", self.score.fail.to_string()) + .color(iced::Color::from_rgb8(255, 79, 0)) + .size(25), + ] + .spacing(10), + button("Закончить").on_press(DictionaryQuizMessage::Back), + ] + .spacing(10) + .align_x(alignment::Horizontal::Center), + ) + .center_y(Fill) + .center_x(Fill) + .into() + } + fn next(&mut self) {} + + fn submit(&mut self) { + if self.view == "---" { + self.show_next(); + return; + } + + if !self.is_help { + self.score.total += 1; + } + if self.answer == self.correct { + if self.is_help == false { + self.score.correct += 1; + } + self.show_next() + } else { + self.score.fail += 1; + self.is_help = true; + } + } + + fn update_set(&mut self) { + self.current_set.append(&mut self.words.clone()); + self.current_set.shuffle(&mut rand::rng()) + } + + fn show_next(&mut self) { + self.is_help = false; + self.answer = String::new(); + + if self.current_set.is_empty() { + self.update_set(); + } + + let next = self.current_set.pop().unwrap(); + if self.reverse { + self.view = next.value.clone(); + self.correct = next.key.clone(); + } else { + self.view = next.key.clone(); + self.correct = next.value.clone(); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0fbeb46..1c3bbb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,14 +3,18 @@ mod lang; mod quiz; mod selector; mod writing; +mod dictionary; +mod dictionary_test; use crate::quiz::*; use crate::selector::*; use crate::writing::{WritingMessage, WritingState}; -use crate::Page::{Quiz, Selector, Writing}; +use crate::Page::{Dictionary, DictionaryQuiz, Quiz, Selector, Writing}; use iced::widget::text; use iced::Task; use iced::Element; +use crate::dictionary::{DictionaryMessage, DictionaryState}; +use crate::dictionary_test::{DictionaryQuizMessage, DictionaryQuizState}; fn main() -> iced::Result { iced::application(ScreenState::boot, ScreenState::update, ScreenState::view) @@ -22,12 +26,16 @@ pub enum RootMessage { Selector(SelectorMessage), Quiz(QuizMessage), Writing(WritingMessage), + Dictionary(DictionaryMessage), + DictionaryQuiz(DictionaryQuizMessage), } enum Page { Selector(SelectorState), Quiz(QuizState), Writing(WritingState), + Dictionary(DictionaryState), + DictionaryQuiz(DictionaryQuizState), PreviousPage, } @@ -53,12 +61,13 @@ impl ScreenState { pub fn boot() -> (ScreenState, Task){ (ScreenState::default(), Task::none()) } - pub fn update(&mut self, message: RootMessage) { - state_update!(message, self.stack, Selector, Quiz, Writing); + pub fn update(&mut self, message: RootMessage) -> Task { + state_update!(message, self.stack, Selector, Quiz, Writing, Dictionary, DictionaryQuiz); + Task::none() } pub fn view(&self) -> Element<'_, RootMessage> { - view_navigation!(self.stack, Quiz, Selector, Writing) + view_navigation!(self.stack, Quiz, Selector, Writing, Dictionary, DictionaryQuiz) } } @@ -95,11 +104,11 @@ macro_rules! message_navigation { if let Some(new_page) = $state.navigate(&$msg) { if let Page::PreviousPage = new_page { $stack.pop(); - return; + return Task::none(); } $stack.push(new_page); } else { - $state.update($msg); + return $state.update($msg); } }; } diff --git a/src/quiz.rs b/src/quiz.rs index 6099bfa..b822dec 100644 --- a/src/quiz.rs +++ b/src/quiz.rs @@ -1,8 +1,8 @@ use crate::lang::KanaSet; use crate::Page::PreviousPage; -use crate::{NavigatedPage, Page}; +use crate::{NavigatedPage, Page, RootMessage}; use iced::widget::*; -use iced::{alignment, Element, Fill}; +use iced::{alignment, Element, Fill, Task}; use rand::seq::SliceRandom; #[derive(Clone, Debug)] @@ -51,14 +51,14 @@ impl Default for QuizState { } impl QuizState { - pub fn update(&mut self, message: QuizMessage) { + pub fn update(&mut self, message: QuizMessage) -> Task { match message { QuizMessage::ContentChanged(content) => { if content.contains("`") { self.is_help = true; self.score.fail += 1; - return; + return Task::none(); } self.current_roman = content; if self.correct_roman == self.current_roman { @@ -77,6 +77,7 @@ impl QuizState { } QuizMessage::Back => todo!(), } + Task::none() } fn update_showed(&mut self) { @@ -144,7 +145,7 @@ pub enum QuizMessage { #[derive(Default, Debug, Clone)] pub struct Score { - total: i32, - correct: i32, - fail: i32, + pub(crate) total: i32, + pub(crate) correct: i32, + pub(crate) fail: i32, } diff --git a/src/selector.rs b/src/selector.rs index 550a68e..b5038a2 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -2,9 +2,10 @@ use crate::lang::{KanaSet, KanaType}; use crate::selector::SelectorMessage::ChangeMode; use crate::writing::WritingState; use crate::Page::{Quiz, Writing}; -use crate::{NavigatedPage, Page, QuizState}; +use crate::{NavigatedPage, Page, QuizState, RootMessage}; use iced::widget::*; -use iced::{alignment, Element}; +use iced::{alignment, Element, Task}; +use crate::dictionary::DictionaryState; pub struct SelectorState { pub set: KanaSet, @@ -26,6 +27,7 @@ pub enum SelectorMessage { Goto, Check(usize, bool), ChangeMode(bool), + ToDictionary, } impl NavigatedPage for SelectorState { @@ -40,27 +42,32 @@ impl NavigatedPage for SelectorState { Some(Quiz(quiz)) }; } + if let SelectorMessage::ToDictionary = message { + return Some(Page::Dictionary(DictionaryState::default())) + } None } } impl SelectorState { - pub fn update(&mut self, message: SelectorMessage) { + pub fn update(&mut self, message: SelectorMessage) -> Task { match message { SelectorMessage::Change => match self.set.chars_type { KanaType::Katakana => self.set = KanaSet::hiragana(), KanaType::Hiragana => self.set = KanaSet::katakana(), }, - SelectorMessage::Goto => {} SelectorMessage::Check(i, b) => self.set.include_map[i] = b, ChangeMode(b) => self.is_writing = b, + _ => {} } + Task::none() } pub fn view(&self) -> Element<'_, SelectorMessage> { container( iced::widget::column![ - button("Переключить").on_press(SelectorMessage::Change), + row![button("Переключить азбуки").on_press(SelectorMessage::Change), + button("Словарь").on_press(SelectorMessage::ToDictionary),].spacing(10), self.rows_selector(), toggler(self.is_writing) .label("Режим письма") @@ -85,12 +92,9 @@ impl SelectorState { for v in &self.set.dictionary[i] { chars_column = chars_column.push( - container( - text!("{}", v.0.clone().to_uppercase()) - .size(36), - ) - .padding(20) - .style(container::rounded_box), + container(text!("{}", v.0.clone().to_uppercase()).size(36)) + .padding(20) + .style(container::rounded_box), ); } diff --git a/src/writing.rs b/src/writing.rs index da01084..4eaa6b9 100644 --- a/src/writing.rs +++ b/src/writing.rs @@ -1,8 +1,8 @@ use crate::lang::KanaSet; use crate::Page::PreviousPage; -use crate::{NavigatedPage, Page}; +use crate::{NavigatedPage, Page, RootMessage}; use iced::widget::*; -use iced::{alignment, Element, Fill}; +use iced::{alignment, Element, Fill, Task}; use rand::seq::SliceRandom; #[derive(Clone, Debug)] @@ -43,12 +43,13 @@ impl WritingState { } impl WritingState { - pub fn update(&mut self, message: WritingMessage) { + pub fn update(&mut self, message: WritingMessage) -> Task { match message { WritingMessage::Back => todo!(), WritingMessage::Next => self.next(), WritingMessage::SwitchShowMode(b) => self.show_all = b, } + Task::none() } fn next(&mut self) {