├── .gitignore ├── pause.png ├── Cargo.toml ├── ideas.md ├── src ├── quad_storage.rs └── main.rs ├── video.md └── learn_words.todo /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | subs/ 4 | *.data -------------------------------------------------------------------------------- /pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/optozorax/learn_words/HEAD/pause.png -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "learn_words" 3 | version = "0.2.1" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | serde = "1.0.124" 10 | ron = "0.6.4" 11 | srtparse = "0.2.0" 12 | chrono = "0.4.19" 13 | strsim = "0.10.0" 14 | rand = "0.7" 15 | rand_pcg = "0.2" 16 | eframe = "0.15.0" 17 | lazy_static = "1.4.0" 18 | 19 | [target.'cfg(not(target_arch = "wasm32"))'.dependencies] 20 | color-backtrace = "0.5.0" 21 | nanoserde = "0.1.29" 22 | 23 | [target.'cfg(target_arch = "wasm32")'.dependencies] 24 | wasm-bindgen = "0.2.75" 25 | js-sys = "0.3.52" 26 | web-sys = "0.3.52" 27 | console_error_panic_hook = "0.1.6" 28 | -------------------------------------------------------------------------------- /ideas.md: -------------------------------------------------------------------------------- 1 | # поле ввода слова 2 | 3 | * режимы: 4 | * показ известного слова 5 | * только после правильного ввода подсказки разрешает переход на следующие 6 | * ввод просто 7 | * показать что это слово введено правильно/неправильно 8 | * кнопка 9 | * фичи 10 | * умеет переключать раскладку 11 | * умеет получать фокус, если является первым полем 12 | * умеет переключать фокус на следующий по enter 13 | * умеет переключать фокус на предыдущий по backspace 14 | * при показывании правильного или неправильного ввода умеет инвертировать результат 15 | * как реализовать режимы: через енум 16 | * как реализовать сохранение данных между разными полями: передавать мутабельную структуру, которая формируется каждый кадр 17 | * как реализовать инвертирование результатов: результаты записываются только после кнопки next 18 | 19 | # новый формат хранения слов 20 | 21 | * русский язык не должен быть в одной лодке вместе с английским, они должны храниться отдельно 22 | * при входе в программу должно настраиваться: что первый язык англ а второй руский 23 | * затем в полях ввода слова итд писать что тут должно быть на английском, а там на русском 24 | * при выборе слов, выбираются только английские, а все русские переводы подмешиваются автоматически 25 | * соответственно в структуре words должно быть две хэш-мапы 26 | 27 | # идеи неизвестной полезности 28 | 29 | * добавить считывание из fb2 книг 30 | * Чтобы можно было настраивать предел для расстояния левенштейна, и чтобы это задавалось в настройках, и одновременно в окне поиска на месте. 31 | * Можно сделать алгоритм для поиска английских слов, который смотрит чтобы меньшее слово было внутри более сложного с максимум одним изменением, таким образом мы сразу найдём всякие -s, -ing, un- итд. Или просто взять модуль нормализации слов. 32 | * интересно было бы отсортировать все слова в порядке количества неправильных попыток 33 | * Чтобы при добавлении слов замерялась их частотность, даже частотность известных, и она просто суммировалась к тому что уже хранится, чтобы можно было видеть частотность выученных слов. Хотя и для этого придётся какой-то сложный интерфейс пилить. 34 | * Интерфейс статистики слова 35 | * Когда добавлено (дата, количество дней назад) 36 | * Все майлстоуны (дней после добавления) -------------------------------------------------------------------------------- /src/quad_storage.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use std::sync::Mutex; 3 | 4 | #[cfg(not(target_arch = "wasm32"))] 5 | use nanoserde::{DeJson, SerJson}; 6 | 7 | #[cfg(not(target_arch = "wasm32"))] 8 | use std::collections::HashMap; 9 | 10 | /// The local storage with methods to read and write data. 11 | #[cfg_attr(not(target_arch = "wasm32"), derive(DeJson, SerJson))] 12 | pub struct LocalStorage { 13 | #[cfg(not(target_arch = "wasm32"))] 14 | local: HashMap, 15 | } 16 | 17 | #[cfg(not(target_arch = "wasm32"))] 18 | const LOCAL_FILE: &str = "local.data"; 19 | 20 | impl Default for LocalStorage { 21 | fn default() -> Self { 22 | #[cfg(target_arch = "wasm32")] 23 | { 24 | Self {} 25 | } 26 | #[cfg(not(target_arch = "wasm32"))] 27 | { 28 | if let Ok(file) = std::fs::read_to_string(LOCAL_FILE) { 29 | LocalStorage::deserialize_json(&file).unwrap() 30 | } else { 31 | LocalStorage { 32 | local: Default::default(), 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | #[cfg(target_arch = "wasm32")] 40 | fn local_storage() -> Option { 41 | web_sys::window()?.local_storage().ok()? 42 | } 43 | 44 | impl LocalStorage { 45 | #[allow(clippy::len_without_is_empty)] 46 | pub fn len(&self) -> usize { 47 | #[cfg(target_arch = "wasm32")] 48 | { 49 | local_storage().and_then(|x| x.length().ok()).unwrap_or(0) as usize 50 | } 51 | #[cfg(not(target_arch = "wasm32"))] 52 | { 53 | self.local.len() 54 | } 55 | } 56 | 57 | /// Get key by its position 58 | pub fn key(&self, pos: usize) -> Option { 59 | #[cfg(target_arch = "wasm32")] 60 | { 61 | local_storage()?.key(pos as u32).ok().flatten() 62 | } 63 | #[cfg(not(target_arch = "wasm32"))] 64 | { 65 | self.local.keys().nth(pos).cloned() 66 | } 67 | } 68 | 69 | pub fn get(&self, key: &str) -> Option { 70 | #[cfg(target_arch = "wasm32")] 71 | { 72 | local_storage()?.get_item(key).ok().flatten() 73 | } 74 | #[cfg(not(target_arch = "wasm32"))] 75 | { 76 | self.local.get(key).cloned() 77 | } 78 | } 79 | pub fn set(&mut self, key: &str, value: &str) { 80 | #[cfg(target_arch = "wasm32")] 81 | { 82 | if let Some(storage) = local_storage() { 83 | storage.set_item(key, value).unwrap(); 84 | } 85 | } 86 | #[cfg(not(target_arch = "wasm32"))] 87 | { 88 | self.local.insert(key.to_string(), value.to_string()); 89 | self.save(); 90 | } 91 | } 92 | pub fn remove(&mut self, key: &str) { 93 | #[cfg(target_arch = "wasm32")] 94 | { 95 | if let Some(storage) = local_storage() { 96 | storage.remove_item(key).unwrap(); 97 | } 98 | } 99 | #[cfg(not(target_arch = "wasm32"))] 100 | { 101 | self.local.remove(key); 102 | self.save(); 103 | } 104 | } 105 | pub fn clear(&mut self) { 106 | #[cfg(target_arch = "wasm32")] 107 | { 108 | if let Some(storage) = local_storage() { 109 | storage.clear().unwrap(); 110 | } 111 | } 112 | #[cfg(not(target_arch = "wasm32"))] 113 | { 114 | self.local.clear(); 115 | self.save(); 116 | } 117 | } 118 | 119 | #[cfg(not(target_arch = "wasm32"))] 120 | fn save(&self) { 121 | std::fs::write(LOCAL_FILE, self.serialize_json()).unwrap(); 122 | } 123 | } 124 | 125 | lazy_static! { 126 | pub static ref STORAGE: Mutex = Mutex::new(Default::default()); 127 | } 128 | -------------------------------------------------------------------------------- /video.md: -------------------------------------------------------------------------------- 1 | # для создания видео 2 | 3 | * засечь время написания идей 4 | * засечь время написания плана 5 | * начать планировать структуры данных, записать это время 6 | * начать кодить программу 7 | * на каждый этап делать коммиты 8 | * условие: всё должно быть сделано за 2 рабочих дня, то есть за 16 часов 9 | 10 | # информация 11 | 12 | * (во времени не учитывается время на написание статей) 13 | * время написания первых идей: 20 минут 14 | * время их ситематизации: 20 минут 15 | * планирование структур данных, связанных с основной логикой, без учёта интерфейса: 1 час 20 минут 16 | * планирование структур и методов интерфейса: 32м (2:32) 17 | * написание кода основной логики: 1 час (3:32) 18 | * написание кода интерфейса до минимально рабочего состояния: 2ч 45м (6:16) 19 | * `6ч 16м` -------------------------------------------------- Минимально рабочее состояние 20 | * сделал два пункта и распланировал дальнейшие идеи в todo: 45м (7:02) 21 | * слетел таймер 22 | * три общих пункта: 47м (0:47) 23 | * основа статистики: 34м (1:21) 24 | * рефакторинг окон: 30м (1:51) 25 | * реализация статистики и других мелких улучшений: 1ч 53м (3:44) 26 | * раскладка клавиатуры: 1ч 4м (4:48) 27 | * github-like плитка: 3ч (7:44) 28 | * написал quad-storage: 1ч 49м (9:33) 29 | * заставил запускаться в васме, пофиксил баги и небольшие дополнения: 1ч 50м (11:23) 30 | * скинул таймер 31 | * написание статьи про программу: 2ч 56м 32 | * `21ч 21м` -------------------------------------------------- Средне рабочее состояние 33 | * закончил править все критичные пункты: 1ч 30м 34 | * начал таймер 35 | * сделал чтобы можно было выбирать количество слов: 44 минуты (0:44) 36 | * сделал чтобы можно было искать по всем словам с fuzzy поиском и это работало при добавлении слова: 1ч 2м (1:46) 37 | * сделал окно для редактирования одного слова: 53м (2:39) 38 | * добавил показ контекста данного слова из текста: 46м (3:15) 39 | * для работы можно выбирать количество новых слов и количество слов для повторения: 21м (3:36) 40 | * добавил вывод информации о количестве слов в тексте после добавления: 20м (3:56) 41 | * фича синхронных субтитров: 3ч 26м (7:22) 42 | * мелкие фичи: 57м (8:19) 43 | * переход на другой генератор рандома: 10м (8:29) 44 | * сбросил таймер 45 | * дописал статью про программу с учётом новых фич и пользования: 2ч 3м 46 | * `33ч 23м` -------------------------------------------------- Первый релиз 47 | * повторение слов после неправильного ввода и инвертирование результатов: 1ч 48м 48 | * улучшения окна add words: 20м 49 | * выбор переводов при выборе слов и выбор наиболее задолженных слов: 43м 50 | * улучшения набора слова: 45м 51 | * улучшения окна edit word: 30м 52 | * задание количества повторений в настройках: 11м 53 | * чтобы текущий день считался правильно с учётом тайм-зоны: 37м 54 | * переехал на egui_template: 2ч 13м 55 | * написал про второй релиз: 30м 56 | * `41ч` -------------------------------------------------- Второй релиз 57 | * добавил скачивание статистики в виде файла: 1ч 15м 58 | * оптимизировал цвета в белой теме: 1ч 2м 59 | * не смог заставить в вебе работать изменение масштаба: 1ч 60 | * написание статьи про imgui: 2ч 44м 61 | * написание статьи про то как пишу программы: 5ч 30м 62 | 63 | # пользование 64 | 65 | * 1й день 66 | * добавление 67 | * S1E1 агентов щит субтитры 68 | * 900 слов 69 | * 50 минут добавлял 70 | * 70 новых слов 71 | * изучение 72 | * 762 попытки 73 | * 681 правильная 74 | * 81 неправильная 75 | * 55 минут писал варианты 76 | * с новой системой 77 | * 272 попытки на 15 новых слов и 30 для повторения, 18 минут. 78 | * 7 августа 79 | * 30 новых слов, 30 слов для повторения 80 | * 331 попытка 81 | * 20 минут 82 | * мой результат 4920 слов по сайту http://testyourvocab.com/result?user=16791060 83 | * текст https://www.lesswrong.com/posts/o5F2p3krzT4JgzqQc/causal-universes 84 | * 3573 слов 85 | * 960 уникальных 86 | * 464 отфильтровано 87 | * 496 неизвестных 88 | * 53 слова реально не знаю 89 | * щас я устал и не хотел изучать новые слова и их добавлять 90 | * надо было повторить 60 слов 91 | * ушло 10м 20с 92 | * попыток 172 93 | * 5000 слов: http://klavogonki.ru/vocs/141923/ 94 | * у меня неизвестных 3105 95 | * 50 слов для повторения и 20 новых слов дают 15 минут работы 96 | 97 | # идеи 98 | 99 | ## как сделал 100 | 101 | * Превью для статьи о том как я пишу программы: наполовину туду выполненная наполовину, четверть идеи, четверть интерфейс. 102 | * Я совершил много ошибок, и щас бы в идеале хотел их пофиксить, но это надо рефакторить, а зачем, если и так всё работает? 103 | * когда я смотрю видео или сериал я прям замечаю слова что изучаю: devour, dismantle -------------------------------------------------------------------------------- /learn_words.todo: -------------------------------------------------------------------------------- 1 | общее: 2 | ✔ чтобы количество слов которое осталось обновлялось после каждого ввода @done (21-08-13 21:59) 3 | ✔ пофиксить баг с тем что слова не все выбираются: выбирать из массива сначала столько слов сколько человек сказал, а лишь затем брать их переводы и их добавлять. или по-другому, если человек сказал что ему нужны все слова, то рассматривать этот случай отдельно @done (21-08-17 23:16) 4 | ✔ обновить прогу для изучения слов на новую версию egui и чекнуть что там для слишком длинных слов теперь без багов работает @done (21-10-28 18:11) 5 | ☐ для stackplot сделать чтобы самые маленькие по площади были в самом низу, а самые большие в самом вверху 6 | ☐ когда слово слишком большое, оно не должно делать окно больше, поле ввода должно стать меньше 7 | ☐ чтобы попытки показывались зелёным до 400 попыток, затем началось синий до тысячи, затем красный до максимального числа, аналогично сделать со временем и так далее. 8 | ☐ мб показывать в окне learn words количество оставшихся попыток? 9 | ☐ чтобы был тест для изученных слов (пока не знаю что и как и зачем, мб к 1 сентября узнаю) 10 | ☐ если переименовываю в существующее слово, то они должны мержиться 11 | 12 | статьи: 13 | ✔ рассказать о quad-storage @done (21-08-10 21:52) 14 | ✔ написать статью о том как сделал эту прогу @done (21-10-28 18:11) 15 | ☐ 1 сентября опубликовать статью по результатам месячного использования этой проги 16 | 17 | далёкая перспектива: 18 | ☐ https://github.com/emilk/egui/issues/595 19 | ☐ в зависимости от того что выключается в stackplot в легенде, убирать это из вычислений, для этого надо внедрить фичу в egui 20 | 21 | рефакторинг: 22 | ✔ причесать функцию ui, вынести поля ввода со всеми их фичами в отдельную функцию, чтобы не было этого копипаста, а данные в отдельную структуру @done (21-08-10 19:27) 23 | ☐ для того чтобы тратилось меньше памяти, и прога работала быстрее за счёт уменьшения числа аллокаций, использовать айдишники строк, а все строки хранить в одной структуре 24 | ☐ переделать так, чтобы в words хранился не массив, где слово может быть выученным и подлежащем изучению, а чтобы сверху там было (trash, known, (learn, и вот уже внутри learn массив (либо выученное, либо изучаемое))) (сомнительное удобство) 25 | ☐ попытаться заюзать gat 26 | 27 | ------------------------------------------------------------------------------------------------------------- 28 | ------------------------------------------------------------------------------------------------------------- 29 | ------------------------------------------------------------------------------------------------------------- 30 | АРХИВ: 31 | ------------------------------------------------------------------------------------------------------------- 32 | ------------------------------------------------------------------------------------------------------------- 33 | ------------------------------------------------------------------------------------------------------------- 34 | 35 | общее: 36 | ✔ сделать чтобы по интерфейсу написания слова можно было легко перемещаться @done (21-07-29 23:34) 37 | ✔ чтобы субтитры могли возвращать ошибку и она показывалась в окне @done (21-07-29 23:41) 38 | ✔ добавить чтобы каждое слово знало свой текущий уровень @done (21-07-30 14:18) 39 | ✔ чтобы из файла считывалась комбинация (Words, Settings), и чтобы она же сохранялась @done (21-07-30 14:23) 40 | ✔ замер времени в программе @done (21-07-30 15:01) 41 | + когда простой мышки или клавиатуры больше 15 секунд, программа переходит в режим паузы, и прекращает замер времени, и это показывается на весь экран 42 | + время в программе за сегодня показывается снизу 43 | + каждый день запоминается количество времени в програме 44 | ✔ сделать чтобы перемещение по интерфейсу ввода слова делалось через enter @done (21-07-31 23:24) 45 | ✔ сделать базовое окно about @done (21-07-31 23:35) 46 | ✔ кажется на васме протекает буфер обмена при использовании русских символов. видимо путаются количество чаров и длина в байтах, надо пофиксить @done (21-08-01 00:10) 47 | 48 | 49 | раскладка: 50 | ✔ сделать окно для раскладки клавиатуры @done (21-07-30 19:30) 51 | + галочка "использовать автопереключение раскладки", и если галочка отмечена, то далее показывается всё что есть 52 | + введите все свои английские символы 53 | + введите все свои русские символы 54 | + если ввести символ не можешь, значит ставить пробел 55 | + чтобы чекалось если вдруг из двух разных языков находятся одинаковые символы, тогда отвергать такую раскладку 56 | + можно ставить enter для удобства 57 | + сравнивалось количество символов без enter, и говорилось когда они совпадают а когда нет 58 | + кнопка "использовать эту раскладку" 59 | ✔ сделать виджеты поля ввода, которое умеет определять текущую раскладку и язык ответа и автоматически подменять буквы @done (21-07-30 19:30) 60 | ✔ раскладка должна храниться в settings @done (21-07-30 19:30) 61 | 62 | статистика: 63 | ✔ основа для замера каждый день @done (21-07-30 15:34) 64 | + замерять количество попыток, правильных и неправильных 65 | + новых неизвестных слов 66 | + обновляется либо вручную, либо при закрытии программы, либо при открытии окна статистики 67 | + заодно замерять количество попыток вообще 68 | ✔ показывать статистику за сегодня внизу @done (21-07-30 15:34) 69 | ✔ запоминание количества слов каждого уровня каждый день @done (21-07-30 15:34) 70 | ✔ статистика количества слов в программе: @done (21-07-30 16:23) 71 | + известные 72 | + мусорные 73 | + изучаемые на каждый уровень 74 | + изученные полностью 75 | + вычисляется при вызове программы из words 76 | ✔ график количества запомненных слов за все дни, условно какую площадь он занимает, со stems, по уровням @done (21-07-30 17:40) 77 | ✔ аналогично верхнему график количества правильных и неправильных попыток @done (21-07-30 17:40) 78 | ✔ плитка как на гитхабе @done (21-07-30 23:36) 79 | + можно выбирать какой параметр показывать, учитывая всё что известно для текущего дня 80 | 81 | васм: 82 | ✔ кнопки для считывания и загрузки своих данных в программу в меню в пункте Data -> {Import, Export} @done (21-07-30 18:03) 83 | ✔ заюзать quad_rand @done (21-07-31 14:51) 84 | ✔ хранение в куках @done (21-07-31 22:20) 85 | + https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Client-side_web_APIs/Client-side_storage 86 | ✔ попробовать скомпилить под васм @done (21-07-31 23:12) 87 | ✔ при закрытии вкладки чтобы автоматически сохранялся прогресс @done (21-07-31 23:12) 88 | 89 | рефакторинг: 90 | ✔ сделать trait ClosableWindow, и функцию process_window, которая обрабатывает окно, которое может закрыться, чтобы не копипастить это постоянно. или лучше структуру, которая оборачивается вокруг структуры окна, умеет закрываться, сама конструирует окно, и сама в функции ui отслеживает когда окно хотят закрыть @done (21-07-30 16:06) 91 | 92 | критичное: 93 | ✔ если просто нажимать кнопку мыши без движения, то выскакивает пауза @done (21-08-02 21:20) 94 | ✔ для того чтобы читать буфер обмена, пользователь должен сам нажать ctrl+v, тогда мб убрать автоматическое считывание буфера обмена, аналогично для ctrl+c @done (21-08-02 21:54) 95 | ✔ подсказки должны даваться с пробелом, а то из-за курсора их не видно @done (21-08-02 21:55) 96 | ✔ чтобы автосохранение было после каждого слова @done (21-08-02 21:56) 97 | ✔ если нажать use this text с пустым текстом, то происходит паника, file: "src/main.rs", line: 1185, col: 38 @done (21-08-02 22:01) 98 | ✔ кажется в копипасте нельзя вставить текст длиньше 32768 символов @done (21-08-02 22:37) 99 | ✔ переделать настройки количества изучения, чтобы они не копипастились в каждое слово, а были глобальны @done (21-08-02 23:12) 100 | + тогда надо чтобы это можно было задавать в окне settings 101 | 102 | сделать следующим: 103 | ✔ при вводе слова с подсказкой необходимо вводить не только перевод слова, но и его самого. это нужно, потому что я пытаюсь ускориться, и не читаю что за слово с подсказкой я пишу @done (21-08-03 17:22) 104 | ✔ наверное за сессию надо изучать меньше слов, а не все добавленные. сделать настройку, которая позволяет взять N (пусть для начала будет 20) слов в пул, и изучать их, и только когда они кончатся, изучать дальше. @done (21-08-03 17:48) 105 | ✔ окно-редактор-просмотрщик слов, где можно искать слова fuzzy поиском, где сразу отображается внутренность @done (21-08-03 20:20) 106 | ✔ автоматическое открытие окна просмотрщика слов, где фильтрование происходит по текущему добавленному слову @done (21-08-03 20:20) 107 | ✔ окно для редактирования одного слова @done (21-08-03 22:16) 108 | ✔ сделать карандашик напротив слова, которое открывает окно для этого слова и позволяет его редактировать @done (21-08-03 22:16) 109 | ✔ всё-таки добавить фичу, показывающую контекст конкретного слова, заодно показывать частоту его встречи в данном тексте @done (21-08-03 23:02) 110 | ✔ должна быть возможность выбирать количество новых слов, и количество слов для повторения @done (21-08-04 14:57) 111 | ✔ после добавления текста или субтитров должно показываться окно с инфой: @done (21-08-04 17:20) 112 | + всего слов 113 | + уникальных слов 114 | + отфильтровано 115 | + известные (known, trash, learned) 116 | + изучаемые (tolearn) 117 | + неизвестных 118 | ✔ синхронные субтитры, когда одновременно показывается и русский и английский вариант @done (21-08-04 23:51) 119 | ✔ разобраться с выделением текста при поиске @done (21-08-04 23:55) 120 | ✔ чтобы когда в вводе ничего нет, или нашлись новые результаты, скролл улетал на начало @done (21-08-04 23:57) 121 | ✔ 0 всегда обозначает отсутствие скролла @done (21-08-04 23:57) 122 | ✔ сделать чтобы скролл целился на лейбл только после нажатия кнопки @done (21-08-04 23:57) 123 | ✔ убрать массив в поиске слова @done (21-08-05 00:03) 124 | ✘ вынести общую часть в коде find_whole_word @cancelled (21-08-05 00:14) 125 | ✔ сделать чтобы нулевой элемент не показывался в кнопочках, и чтобы не выделялся, и чтобы на него нельзя было попасть @done (21-08-05 00:14) 126 | ✔ в окне добавления слова может унести контекст вправо @done (21-08-05 13:52) 127 | ✔ возможность менять масштаб в настройках @done (21-08-05 14:02) 128 | ✔ должна быть галочка, позволяющая двигать график @done (21-08-05 14:19) 129 | ✔ нужна белая тема, ибо на тёмной глазам неприятно @done (21-08-05 14:35) 130 | + запоминать тему в настройки 131 | + рисовать всё окно белым или чёрным в зависимости от темы 132 | + запомнить цвета для всяких штук типо activity в зависимости от темы 133 | ✔ заюзать нормальный рандом, который инициализируется текущим временем @done (21-08-05 19:22) 134 | ✔ после ввода какого-то слова неправильно, надо его снова ввести с подсказкой, и переходить дальше не разрешит, пока правильно не напишешь @done (21-08-10 16:34) 135 | + после неправильного ввода слова его надо снова написать даже несколько раз 136 | ✔ пропускать окно проверки для ввода с подсказкой @done (21-08-10 16:34) 137 | ✔ после ввода слова должна быть возможность инвертировать правильный и неправильный результаты @done (21-08-10 16:34) 138 | + чтобы там использовалось right_to_left 139 | + для этого надо регистрировать попытки не после их ввода, а после нажатия кнопки "next" 140 | ✔ чтобы если нажимается backspace на пустом поле или кнопке, фокус запрашивался назад @done (21-08-10 17:54) 141 | ✘ перевести Words на хранение двух разных языков, и при выборе слов для набора считать только английские слова @cancelled (21-08-10 18:21) 142 | ✘ сортировать слова для добавления не по их алфавитному написанию, а по порядку как они встречаются в тексте, чтобы лишний раз не читать одни и те же предложения @cancelled (21-08-10 21:51) 143 | --- 144 | ✔ в окне add words должно быть поле где можно добавлять известные переводы этого слова @done (21-08-10 18:12) 145 | ✔ сделать кнопку для скипа добавляемых слов @done (21-08-10 18:12) 146 | ✔ нужна возможность отменить предыдущее нажатие при добавлении слова, а то так можешь быстро нажимать что знаешь слово и раз, пропустил одно @done (21-08-10 18:12) 147 | + запоминать одно слово, и удалять его из words методом для удаления, если нажалась кнопка back 148 | --- 149 | ✔ сделать чтобы при выборе слов добавлялись сразу переводы, и выбор останавливался когда набиралось больше чем нужное количество, или все слова кончались @done (21-08-10 19:02) 150 | ✔ в первую очередь должны выбираться наиболее старые слова в окне выбора слов @done (21-08-10 19:02) 151 | ✔ писать сколько осталось набрать это слово сегодня @done (21-08-10 19:12) 152 | ✔ чтобы на кнопке при нажатии backspace отправляло назад @done (21-08-10 19:16) 153 | ✔ должна быть кнопка отмены текущего набора и выбора количества слов для изучения @done (21-08-10 19:26) 154 | ✔ наверное лучше сначала набрать все слова, которые ты не знаешь с подсказкой, в рандомном порядке, а уже затем набирать все слова которые надо без подсказки набирать причём надо сделать не просто выбор рандома, а чтобы нормально shuffle'ился весь массив, аналогично всё остальное, чтобы подряд не шло два раза одно и то же слово никогда @done (21-08-10 19:46) 155 | --- 156 | ✔ чтобы при переименовании перевода, перевод тоже переименовывался нормально @done (21-08-11 18:15) 157 | ✔ сохранять всё после изменения слова @done (21-08-11 18:15) 158 | ✔ в окне edit word должна быть возможность удалять конкретный перевод слова, и добавлять новые, в окне выставления дня должно автоматически ставиться сегодняшний день @done (21-08-11 18:25) 159 | --- 160 | ✔ чтобы через настройки можно было задавать уровни и количество повторений. @done (21-08-11 18:39) 161 | --- 162 | ✔ сделать чтобы текущий день считался локальным, а то у меня в 0:00 день был не сегодняшний @done (21-08-11 19:04) 163 | ✘ законтрибьютить user_dpi в egui-miniquad @cancelled (21-08-11 19:27) 164 | ✘ мб попробовать отображать панику на экране @cancelled (21-08-11 19:27) 165 | ✔ баг: не все слова выбираются когда выбираешь для повторения то, что не имеет перевода для повторения @done (21-08-11 23:02) 166 | --- 167 | ✔ перейти на egui-web, egui-glium @done (21-08-11 22:25) 168 | ✔ добавить возможность скачивать экспорт как файл @done (21-08-12 14:58) 169 | ✘ добавить кнопку, которая выделяет всё @cancelled (21-08-12 14:58) 170 | --- 171 | ✔ оптимизировать цвета в белой теме, мб через отдельное окно @done (21-08-13 19:18) 172 | ✘ заставить в вебе работать изменение масштаба @cancelled (21-08-13 20:24) 173 | 174 | не криитчное: 175 | ✔ кажется нативное приложение не хочет сохранять статистику в файл @done (21-08-01 23:06) 176 | ✔ кажется окно добавления слов не фильтрует известные слова @done (21-08-01 23:12) 177 | ✔ невозможно нажать пробел из-за замены символов @done (21-08-02 21:06) 178 | ✔ слова должны быть огромными, а не простой label @done (21-08-02 21:06) 179 | ✔ чтобы слова можно было удалять @done (21-08-03 23:15) 180 | ✘ при вводе слова должна быть возможность посмотреть его статистику @cancelled (21-08-03 23:15) 181 | ✘ при вводе в попытках слова должна быть возможность отредактировать это слово (типо иногда оставил лишнее окончание или что-то такое) @cancelled (21-08-03 23:15) 182 | ✔ в окне добавления слов по тексту показывать сколько было уникальных, а сколько отфильтровалось @done (21-08-05 14:35) 183 | ✔ добавить learned translations в окно добавления слов @done (21-08-05 14:36) 184 | ✘ в github стате затемнять на dim 0 те элементы где 0 @cancelled (21-08-05 14:39) 185 | ✘ должна быть возможность добавлять алиасы для какого-то слова, типо если ты его ввёл не так, чтобы оно считалось тоже правильным. @cancelled (21-08-11 19:04) 186 | + тогда должна быть кнопка после ввода слова, которая не только отменяет неверность текущего ответа, но и одновременно добавляет его в алисы 187 | + это не нужно, так как есть кнопка invert -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::collapsible_else_if)] 2 | 3 | pub mod quad_storage; 4 | 5 | use ::rand::prelude::*; 6 | use serde::*; 7 | use std::collections::BTreeMap; 8 | use std::collections::BTreeSet; 9 | // use eframe::egui_web; 10 | 11 | type Rand = rand_pcg::Pcg64; 12 | 13 | macro_rules! err { 14 | () => { 15 | // todo 16 | // egui_web::console_error(format!("error at {}:{}", file!(), line!())); 17 | }; 18 | } 19 | 20 | /// День 21 | #[derive(Serialize, Deserialize, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)] 22 | pub struct Day(u64); 23 | 24 | impl std::fmt::Debug for Day { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | write!(f, "Day({})", self.0) 27 | } 28 | } 29 | 30 | /// Итерация изучения слова, сколько ждать с последнего изучения, сколько раз повторить, показывать ли слово во время набора 31 | #[derive(Serialize, Deserialize, Clone)] 32 | struct LearnType { 33 | /// Сколько дней ждать с последнего изучения 34 | wait_days: u8, 35 | count: u8, 36 | show_word: bool, 37 | } 38 | 39 | impl std::fmt::Debug for LearnType { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | write!( 42 | f, 43 | "(wait {}, count {}, {})", 44 | self.wait_days, 45 | self.count, 46 | if self.show_word { "show" } else { "not show" } 47 | ) 48 | } 49 | } 50 | 51 | impl LearnType { 52 | fn show(wait_days: u8, count: u8) -> Self { 53 | LearnType { 54 | wait_days, 55 | count, 56 | show_word: true, 57 | } 58 | } 59 | 60 | fn guess(wait_days: u8, count: u8) -> Self { 61 | LearnType { 62 | wait_days, 63 | count, 64 | show_word: false, 65 | } 66 | } 67 | } 68 | 69 | impl LearnType { 70 | fn can_learn_today(&self, last_learn: Day, today: Day) -> bool { 71 | if today.0 >= last_learn.0 { 72 | today.0 - last_learn.0 >= self.wait_days as u64 73 | } else { 74 | false 75 | } 76 | } 77 | } 78 | 79 | /// Статистика написаний для слова, дня или вообще 80 | #[derive(Default, Serialize, Deserialize, Clone, Copy, Debug)] 81 | struct TypingStats { 82 | right: u64, 83 | wrong: u64, 84 | } 85 | 86 | /// Обозначает одну пару слов рус-англ или англ-рус в статистике 87 | #[derive(Serialize, Deserialize, Clone, Debug)] 88 | enum WordStatus { 89 | /// Мы знали это слово раньше, его изучать не надо 90 | KnowPreviously, 91 | 92 | /// Мусорное слово, артефакт от приблизительного парсинга текстового файла или субтитров 93 | TrashWord, 94 | 95 | /// Мы изучаем это слово 96 | ToLearn { 97 | translation: String, 98 | 99 | /// Когда это слово в последний раз изучали 100 | last_learn: Day, 101 | 102 | /// Количество learns, которое уже преодолено 103 | current_level: u8, 104 | 105 | /// Количество вводов для текущего уровня 106 | current_count: u8, 107 | 108 | /// Статистика 109 | stats: TypingStats, 110 | }, 111 | 112 | // Мы знаем это слово 113 | Learned { 114 | translation: String, 115 | 116 | /// Статистика 117 | stats: TypingStats, 118 | }, 119 | } 120 | 121 | impl WordStatus { 122 | fn register_attempt( 123 | &mut self, 124 | correct: bool, 125 | today: Day, 126 | day_stats: &mut DayStatistics, 127 | type_count: &[LearnType], 128 | ) { 129 | use WordStatus::*; 130 | match self { 131 | KnowPreviously | TrashWord | Learned { .. } => unreachable!(), 132 | ToLearn { 133 | stats, 134 | last_learn, 135 | translation, 136 | current_level, 137 | current_count, 138 | } => { 139 | if correct { 140 | stats.right += 1; 141 | day_stats.attempts.right += 1; 142 | } else { 143 | stats.wrong += 1; 144 | day_stats.attempts.wrong += 1; 145 | } 146 | 147 | if correct { 148 | for learn in type_count.iter().skip(*current_level as _) { 149 | if learn.can_learn_today(*last_learn, today) { 150 | if *current_count + 1 != learn.count { 151 | *current_count += 1; 152 | } else { 153 | *last_learn = today; 154 | *current_level += 1; 155 | *current_count = 0; 156 | } 157 | break; 158 | } 159 | } 160 | 161 | if *current_level as usize == type_count.len() { 162 | *self = WordStatus::Learned { 163 | translation: translation.clone(), 164 | stats: *stats, 165 | }; 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | fn has_translation(&self, translation2: &str) -> bool { 173 | use WordStatus::*; 174 | match self { 175 | KnowPreviously | TrashWord => false, 176 | ToLearn { translation, .. } | Learned { translation, .. } => { 177 | translation == translation2 178 | } 179 | } 180 | } 181 | 182 | fn has_hint(&self, type_count: &[LearnType]) -> bool { 183 | use WordStatus::*; 184 | match self { 185 | KnowPreviously | TrashWord | Learned { .. } => false, 186 | ToLearn { current_level, .. } => type_count 187 | .get(*current_level as usize) 188 | .map(|x| x.show_word) 189 | .unwrap_or(false), 190 | } 191 | } 192 | 193 | fn can_learn_today(&self, today: Day, type_count: &[LearnType]) -> bool { 194 | if let WordStatus::ToLearn { 195 | last_learn, 196 | current_level, 197 | .. 198 | } = self 199 | { 200 | type_count 201 | .get(*current_level as usize) 202 | .map(|learn| learn.can_learn_today(*last_learn, today)) 203 | .unwrap_or(false) 204 | } else { 205 | false 206 | } 207 | } 208 | 209 | fn translation(&self) -> Option<&str> { 210 | use WordStatus::*; 211 | if let ToLearn { translation, .. } | Learned { translation, .. } = self { 212 | Some(translation) 213 | } else { 214 | None 215 | } 216 | } 217 | 218 | fn translation_mut(&mut self) -> Option<&mut String> { 219 | use WordStatus::*; 220 | if let ToLearn { translation, .. } | Learned { translation, .. } = self { 221 | Some(translation) 222 | } else { 223 | None 224 | } 225 | } 226 | 227 | fn level(&self) -> Option { 228 | use WordStatus::*; 229 | if let ToLearn { current_level, .. } = self { 230 | Some(*current_level) 231 | } else { 232 | None 233 | } 234 | } 235 | 236 | fn overdue_days(&self, today: Day, type_count: &[LearnType]) -> u64 { 237 | use WordStatus::*; 238 | if let ToLearn { 239 | last_learn, 240 | current_level, 241 | .. 242 | } = self 243 | { 244 | let date_to_learn = last_learn.0 + type_count[*current_level as usize].wait_days as u64; 245 | if today.0 > date_to_learn { 246 | 0 247 | } else { 248 | date_to_learn - today.0 249 | } 250 | } else { 251 | 0 252 | } 253 | } 254 | 255 | fn attempts_remains(&self, today: Day, type_count: &[LearnType]) -> u8 { 256 | use WordStatus::*; 257 | if let ToLearn { 258 | last_learn, 259 | current_level, 260 | current_count, 261 | .. 262 | } = self 263 | { 264 | if let Some(learn) = type_count.get(*current_level as usize) { 265 | if learn.can_learn_today(*last_learn, today) { 266 | learn.count - current_count 267 | } else { 268 | 0 269 | } 270 | } else { 271 | 0 272 | } 273 | } else { 274 | 0 275 | } 276 | } 277 | } 278 | 279 | /// Все слова в программе 280 | #[derive(Default, Serialize, Deserialize, Clone, Debug)] 281 | pub struct Words(BTreeMap>); 282 | 283 | enum WordsToAdd { 284 | KnowPreviously, 285 | TrashWord, 286 | ToLearn { 287 | learned: Vec, 288 | translations: Vec, 289 | }, 290 | } 291 | 292 | struct WordsToLearn { 293 | known_words: Vec, 294 | words_to_type: Vec, 295 | words_to_guess: Vec, 296 | } 297 | 298 | impl Words { 299 | fn calculate_known_words(&self) -> BTreeSet { 300 | self.0.iter().map(|(word, _)| word.clone()).collect() 301 | } 302 | 303 | fn add_word( 304 | &mut self, 305 | word: String, 306 | info: WordsToAdd, 307 | today: Day, 308 | day_stats: &mut DayStatistics, 309 | ) { 310 | use WordsToAdd::*; 311 | let entry = self.0.entry(word.clone()).or_insert_with(Vec::new); 312 | match info { 313 | KnowPreviously => entry.push(WordStatus::KnowPreviously), 314 | TrashWord => entry.push(WordStatus::TrashWord), 315 | ToLearn { 316 | learned, 317 | translations, 318 | } => { 319 | for translation in &translations { 320 | entry.push(WordStatus::ToLearn { 321 | translation: translation.clone(), 322 | last_learn: today, 323 | current_level: 0, 324 | current_count: 0, 325 | stats: Default::default(), 326 | }); 327 | day_stats.new_unknown_words_count += 1; 328 | } 329 | for translation in &learned { 330 | entry.push(WordStatus::Learned { 331 | translation: translation.clone(), 332 | stats: Default::default(), 333 | }); 334 | day_stats.new_unknown_words_count += 1; 335 | } 336 | for translation in translations { 337 | self.0 338 | .entry(translation) 339 | .or_insert_with(Vec::new) 340 | .push(WordStatus::ToLearn { 341 | translation: word.clone(), 342 | last_learn: today, 343 | current_level: 0, 344 | current_count: 0, 345 | stats: Default::default(), 346 | }); 347 | } 348 | for translation in learned { 349 | self.0 350 | .entry(translation) 351 | .or_insert_with(Vec::new) 352 | .push(WordStatus::Learned { 353 | translation: word.clone(), 354 | stats: Default::default(), 355 | }); 356 | } 357 | } 358 | } 359 | } 360 | 361 | fn is_learned(&self, word: &str) -> bool { 362 | if let Some(word) = self.0.get(word) { 363 | for i in word { 364 | if matches!(i, WordStatus::ToLearn { .. }) { 365 | return false; 366 | } 367 | } 368 | true 369 | } else { 370 | err!(); 371 | true 372 | } 373 | } 374 | 375 | fn has_hint(&self, word: &str, type_count: &[LearnType]) -> bool { 376 | if let Some(word) = self.0.get(word) { 377 | word.iter().any(|x| x.has_hint(type_count)) 378 | } else { 379 | false 380 | } 381 | } 382 | 383 | fn get_word_to_learn(&self, word: &str, today: Day, type_count: &[LearnType]) -> WordsToLearn { 384 | let mut known_words = Vec::new(); 385 | let mut words_to_type = Vec::new(); 386 | let mut words_to_guess = Vec::new(); 387 | for i in self.0.get(word).unwrap() { 388 | if let WordStatus::ToLearn { 389 | translation, 390 | last_learn, 391 | current_level, 392 | .. 393 | } = i 394 | { 395 | for learn in type_count.iter().skip(*current_level as _) { 396 | if learn.can_learn_today(*last_learn, today) { 397 | if learn.show_word { 398 | words_to_type.push(translation.clone()); 399 | } else { 400 | words_to_guess.push(translation.clone()); 401 | } 402 | break; 403 | } 404 | } 405 | if type_count 406 | .iter() 407 | .skip(*current_level as _) 408 | .all(|x| !x.can_learn_today(*last_learn, today)) 409 | { 410 | known_words.push(translation.clone()); 411 | } 412 | } else if let WordStatus::Learned { translation, .. } = i { 413 | known_words.push(translation.clone()); 414 | } 415 | } 416 | WordsToLearn { 417 | known_words, 418 | words_to_type, 419 | words_to_guess, 420 | } 421 | } 422 | 423 | fn get_words_to_learn_today( 424 | &self, 425 | today: Day, 426 | type_count: &[LearnType], 427 | ) -> (Vec, Vec) { 428 | let mut new = Vec::new(); 429 | let mut repeat = Vec::new(); 430 | for (word, statuses) in &self.0 { 431 | if statuses 432 | .iter() 433 | .any(|x| x.can_learn_today(today, type_count)) 434 | { 435 | if statuses.iter().any(|x| x.level() == Some(0)) { 436 | new.push(word.clone()); 437 | } else { 438 | repeat.push(word.clone()); 439 | } 440 | } 441 | } 442 | (repeat, new) 443 | } 444 | 445 | fn register_attempt( 446 | &mut self, 447 | word: &str, 448 | translation: &str, 449 | correct: bool, 450 | today: Day, 451 | day_stats: &mut DayStatistics, 452 | type_count: &[LearnType], 453 | ) { 454 | if let Some(word) = self.0.get_mut(word) { 455 | for i in word { 456 | if i.has_translation(translation) { 457 | i.register_attempt(correct, today, day_stats, type_count); 458 | return; 459 | } 460 | } 461 | err!(); 462 | } else { 463 | err!(); 464 | } 465 | } 466 | 467 | fn calculate_word_statistics(&self) -> BTreeMap { 468 | let mut result = BTreeMap::new(); 469 | for i in self.0.values().flatten() { 470 | use WordStatus::*; 471 | match i { 472 | KnowPreviously => *result.entry(WordType::Known).or_insert(0) += 1, 473 | TrashWord => *result.entry(WordType::Trash).or_insert(0) += 1, 474 | ToLearn { current_level, .. } => { 475 | *result.entry(WordType::Level(*current_level)).or_insert(0) += 1 476 | } 477 | Learned { .. } => *result.entry(WordType::Learned).or_insert(0) += 1, 478 | } 479 | } 480 | result 481 | } 482 | 483 | fn calculate_attempts_statistics(&self) -> TypingStats { 484 | let mut result = TypingStats::default(); 485 | for i in self.0.values().flatten() { 486 | if let WordStatus::ToLearn { stats, .. } = i { 487 | result.right += stats.right; 488 | result.wrong += stats.wrong; 489 | } 490 | } 491 | result 492 | } 493 | 494 | fn remove_word(&mut self, word: &str) { 495 | let translations: Vec = self 496 | .0 497 | .remove(word) 498 | .unwrap() 499 | .into_iter() 500 | .filter_map(|x| x.translation().map(|x| x.to_owned())) 501 | .collect(); 502 | 503 | for translation in translations { 504 | if let Some(to_edit) = self.0.get_mut(&translation) { 505 | *to_edit = to_edit 506 | .iter() 507 | .filter(|w| w.translation().map(|x| x != word).unwrap_or(true)) 508 | .cloned() 509 | .collect(); 510 | } 511 | if self.0.get(word).map(|x| x.is_empty()).unwrap_or(false) { 512 | self.0.remove(&translation); 513 | } 514 | } 515 | } 516 | 517 | fn rename_word(&mut self, word: &str, new_word: &str) { 518 | let status = self.0.remove(word).unwrap(); 519 | let translations: Vec = status 520 | .iter() 521 | .filter_map(|x| x.translation().map(|x| x.to_owned())) 522 | .collect(); 523 | self.0.insert(new_word.to_owned(), status); 524 | 525 | for translation in translations { 526 | if let Some(to_edit) = self.0.get_mut(&translation) { 527 | *to_edit = to_edit 528 | .iter() 529 | .cloned() 530 | .map(|mut w| { 531 | if let Some(tr) = w.translation_mut() { 532 | if tr == word { 533 | *tr = new_word.to_string(); 534 | } 535 | } 536 | w 537 | }) 538 | .collect(); 539 | } 540 | } 541 | } 542 | 543 | fn max_overdue_days(&self, word: &str, today: Day, type_count: &[LearnType]) -> u64 { 544 | if let Some(trs) = self.0.get(word) { 545 | trs.iter() 546 | .map(|x| x.overdue_days(today, type_count)) 547 | .max() 548 | .unwrap_or(0) 549 | } else { 550 | 0 551 | } 552 | } 553 | 554 | fn max_attempts_remains(&self, word: &str, today: Day, type_count: &[LearnType]) -> u8 { 555 | if let Some(trs) = self.0.get(word) { 556 | trs.iter() 557 | .map(|x| x.attempts_remains(today, type_count)) 558 | .max() 559 | .unwrap_or(0) 560 | } else { 561 | 0 562 | } 563 | } 564 | 565 | fn can_learn_today(&self, word: &str, today: Day, type_count: &[LearnType]) -> bool { 566 | self.0 567 | .get(word) 568 | .map(|x| x.iter().any(|x| x.can_learn_today(today, type_count))) 569 | .unwrap_or(false) 570 | } 571 | } 572 | 573 | fn get_words_subtitles(subtitles: &str) -> Result { 574 | let subtitles = srtparse::from_str(subtitles)?; 575 | let text = subtitles 576 | .into_iter() 577 | .map(|x| x.text) 578 | .collect::>() 579 | .join("\n"); 580 | 581 | Ok(get_words(&text)) 582 | } 583 | 584 | struct WordsWithContext(Vec<(String, Vec>)>); 585 | 586 | struct GetWordsResult { 587 | text: String, 588 | words_with_context: WordsWithContext, 589 | words_count: usize, 590 | unique_words_count: usize, 591 | } 592 | 593 | fn is_word_symbol(c: char) -> bool { 594 | c.is_alphabetic() || c == '\'' || c == '-' 595 | } 596 | 597 | fn get_words(text: &str) -> GetWordsResult { 598 | let mut words_count = 0; 599 | let mut words = BTreeMap::new(); 600 | let mut current_word: Option<(String, usize)> = None; 601 | for (i, c) in text 602 | .char_indices() 603 | .chain(std::iter::once((text.len(), '.'))) 604 | { 605 | if is_word_symbol(c) { 606 | if let Some((word, _)) = &mut current_word { 607 | *word += &c.to_lowercase().collect::(); 608 | } else { 609 | current_word = Some((c.to_lowercase().collect(), i)); 610 | } 611 | } else if let Some((word, start)) = &mut current_word { 612 | words_count += 1; 613 | words 614 | .entry(word.clone()) 615 | .or_insert_with(Vec::new) 616 | .push(*start..i); 617 | current_word = None; 618 | } 619 | } 620 | let mut words: Vec<_> = words.into_iter().collect(); 621 | 622 | words.sort_by_key(|x| std::cmp::Reverse(x.1.len())); 623 | 624 | let unique_words_count = words.len(); 625 | 626 | GetWordsResult { 627 | text: text.to_owned(), 628 | words_with_context: WordsWithContext(words), 629 | words_count, 630 | unique_words_count, 631 | } 632 | } 633 | 634 | #[derive(Serialize, Deserialize, Clone, Debug)] 635 | pub struct Settings { 636 | type_count: Vec, 637 | time_to_pause: f64, 638 | use_keyboard_layout: bool, 639 | keyboard_layout: KeyboardLayout, 640 | dpi: f32, 641 | #[serde(default)] 642 | white_theme: bool, 643 | } 644 | 645 | #[derive(Default, Serialize, Deserialize, Clone, Debug)] 646 | struct KeyboardLayout { 647 | lang1: BTreeMap, 648 | lang2: BTreeMap, 649 | } 650 | 651 | impl KeyboardLayout { 652 | fn new(lang1: &str, lang2: &str) -> Result { 653 | let a: Vec = lang1.chars().filter(|x| *x != '\n').collect(); 654 | let b: Vec = lang2.chars().filter(|x| *x != '\n').collect(); 655 | if a.len() != b.len() { 656 | return Err(format!( 657 | "Lengths of symbols are not equal: {} ≠ {}", 658 | a.len(), 659 | b.len() 660 | )); 661 | } 662 | 663 | let mut error_reason = (' ', ' '); 664 | if a.iter().filter(|a| **a != ' ').any(|a| { 665 | b.iter().any(|x| { 666 | let result = *x == *a; 667 | if result { 668 | error_reason = (*x, *a); 669 | } 670 | result 671 | }) 672 | }) { 673 | return Err(format!("In first lang there is symbol '{}', which equals to symbol '{}' in the second lang.", error_reason.0, error_reason.1)); 674 | } 675 | 676 | let mut result = Self { 677 | lang1: Default::default(), 678 | lang2: Default::default(), 679 | }; 680 | 681 | for (a, b) in a.iter().zip(b.iter()) { 682 | result.lang1.insert(*a, *b); 683 | result.lang2.insert(*b, *a); 684 | } 685 | 686 | Ok(result) 687 | } 688 | 689 | fn change(&self, should_be: &str, to_change: &mut String) { 690 | let is_first_lang = self.lang2.contains_key(&should_be.chars().next().unwrap()); 691 | let lang = if is_first_lang { 692 | &self.lang1 693 | } else { 694 | &self.lang2 695 | }; 696 | *to_change = to_change 697 | .chars() 698 | .map(|x| { 699 | if let Some(c) = lang.get(&x).filter(|_| x != ' ') { 700 | *c 701 | } else { 702 | x 703 | } 704 | }) 705 | .collect(); 706 | } 707 | } 708 | 709 | impl Default for Settings { 710 | fn default() -> Self { 711 | Settings { 712 | type_count: vec![ 713 | LearnType::show(0, 2), 714 | LearnType::guess(0, 3), 715 | LearnType::guess(2, 3), 716 | LearnType::guess(7, 2), 717 | LearnType::guess(20, 2), 718 | ], 719 | time_to_pause: 15., 720 | use_keyboard_layout: false, 721 | keyboard_layout: Default::default(), 722 | dpi: 1.0, 723 | white_theme: false, 724 | } 725 | } 726 | } 727 | 728 | impl Settings { 729 | fn color_github_zero(&self) -> egui::Color32 { 730 | if self.white_theme { 731 | egui::Color32::from_gray(240) 732 | } else { 733 | egui::Color32::from_gray(24) 734 | } 735 | } 736 | 737 | fn color_github_high(&self) -> egui::Color32 { 738 | if self.white_theme { 739 | egui::Color32::from_rgb(33, 110, 57) 740 | } else { 741 | egui::Color32::from_rgba_unmultiplied(0, 255, 128, 255) 742 | } 743 | } 744 | 745 | fn color_github_low(&self) -> egui::Color32 { 746 | if self.white_theme { 747 | egui::Color32::from_rgb(155, 233, 168) 748 | } else { 749 | egui::Color32::from_rgba_unmultiplied(5, 101, 5, 255) 750 | } 751 | } 752 | 753 | fn color_github_month(&self) -> egui::Color32 { 754 | if self.white_theme { 755 | egui::Color32::from_rgba_unmultiplied(76, 76, 76, 255) 756 | } else { 757 | egui::Color32::WHITE 758 | } 759 | } 760 | 761 | fn color_github_year(&self) -> egui::Color32 { 762 | if self.white_theme { 763 | egui::Color32::from_rgba_unmultiplied(226, 31, 31, 255) 764 | } else { 765 | egui::Color32::RED 766 | } 767 | } 768 | 769 | fn color_delete(&self) -> egui::Color32 { 770 | if self.white_theme { 771 | egui::Color32::from_rgba_unmultiplied(213, 0, 0, 255) 772 | } else { 773 | egui::Color32::RED 774 | } 775 | } 776 | 777 | fn color_add(&self) -> egui::Color32 { 778 | if self.white_theme { 779 | egui::Color32::from_rgba_unmultiplied(0, 171, 0, 255) 780 | } else { 781 | egui::Color32::RED 782 | } 783 | } 784 | 785 | fn color_error(&self) -> egui::Color32 { 786 | if self.white_theme { 787 | egui::Color32::from_rgba_unmultiplied(255, 0, 0, 255) 788 | } else { 789 | egui::Color32::RED 790 | } 791 | } 792 | 793 | fn color_red_field_1(&self) -> egui::Color32 { 794 | if self.white_theme { 795 | egui::Color32::from_rgba_unmultiplied(255, 0, 0, 255) 796 | } else { 797 | egui::Color32::RED 798 | } 799 | } 800 | 801 | fn color_red_field_2(&self) -> egui::Color32 { 802 | if self.white_theme { 803 | egui::Color32::from_rgba_unmultiplied(224, 0, 0, 200) 804 | } else { 805 | egui::Color32::from_rgb_additive(128, 0, 0) 806 | } 807 | } 808 | 809 | fn color_red_field_3(&self) -> egui::Color32 { 810 | if self.white_theme { 811 | egui::Color32::from_rgba_unmultiplied(255, 128, 128, 255) 812 | } else { 813 | egui::Color32::from_rgb_additive(255, 128, 128) 814 | } 815 | } 816 | 817 | fn color_green_field_1(&self) -> egui::Color32 { 818 | if self.white_theme { 819 | egui::Color32::from_rgba_unmultiplied(0, 255, 0, 255) 820 | } else { 821 | egui::Color32::GREEN 822 | } 823 | } 824 | 825 | fn color_green_field_2(&self) -> egui::Color32 { 826 | if self.white_theme { 827 | egui::Color32::from_rgba_unmultiplied(0, 195, 63, 201) 828 | } else { 829 | egui::Color32::from_rgb_additive(0, 128, 0) 830 | } 831 | } 832 | 833 | fn color_green_field_3(&self) -> egui::Color32 { 834 | if self.white_theme { 835 | egui::Color32::from_rgba_unmultiplied(47, 198, 0, 191) 836 | } else { 837 | egui::Color32::from_rgb_additive(128, 255, 128) 838 | } 839 | } 840 | } 841 | 842 | #[derive(Serialize, Deserialize, Clone, Debug, Ord, PartialOrd, Eq, PartialEq)] 843 | pub enum WordType { 844 | Known, 845 | Trash, 846 | Level(u8), 847 | Learned, 848 | } 849 | 850 | #[derive(Default, Serialize, Deserialize, Clone, Debug)] 851 | pub struct DayStatistics { 852 | attempts: TypingStats, 853 | new_unknown_words_count: u64, 854 | word_count_by_level: BTreeMap, 855 | working_time: f64, 856 | } 857 | 858 | #[derive(Default, Serialize, Deserialize, Clone, Debug)] 859 | pub struct Statistics { 860 | by_day: BTreeMap, 861 | } 862 | 863 | mod gui { 864 | use super::*; 865 | use egui::*; 866 | 867 | struct ClosableWindow(Option); 868 | 869 | impl Default for ClosableWindow { 870 | fn default() -> Self { 871 | Self(None) 872 | } 873 | } 874 | 875 | trait WindowTrait { 876 | fn create_window(&self) -> Window<'static>; 877 | } 878 | 879 | impl ClosableWindow { 880 | fn new(t: T) -> Self { 881 | Self(Some(t)) 882 | } 883 | 884 | /// Возвращение true в f означает что самого себя надо закрыть. Возвращение true в ui означает что окно закрылось 885 | fn ui(&mut self, ctx: &CtxRef, f: impl FnOnce(&mut T, &mut Ui) -> bool) -> bool { 886 | if let Some(t) = &mut self.0 { 887 | let mut opened = true; 888 | let mut want_to_be_closed = false; 889 | 890 | t.create_window() 891 | .open(&mut opened) 892 | .show(ctx, |ui| want_to_be_closed = f(t, ui)); 893 | 894 | if !opened || want_to_be_closed { 895 | self.0 = None; 896 | return true; 897 | } 898 | } 899 | false 900 | } 901 | } 902 | 903 | pub struct Program { 904 | words: Words, 905 | settings: Settings, 906 | stats: Statistics, 907 | 908 | /// Известные, мусорные, выученные, добавленные слова, необходимо для фильтрации после добавления слова 909 | known_words: BTreeSet, 910 | learn_window: LearnWordsWindow, 911 | load_text_window: ClosableWindow, 912 | add_words_window: ClosableWindow, 913 | add_custom_words_window: ClosableWindow, 914 | 915 | full_stats_window: ClosableWindow, 916 | percentage_graph_window: ClosableWindow, 917 | github_activity_window: ClosableWindow, 918 | 919 | import_window: ClosableWindow, 920 | export_window: ClosableWindow, 921 | settings_window: ClosableWindow, 922 | about_window: ClosableWindow, 923 | search_words_window: ClosableWindow, 924 | edit_word_window: ClosableWindow, 925 | info_window: ClosableWindow, 926 | synchronous_subtitles_window: ClosableWindow, 927 | } 928 | 929 | impl Program { 930 | pub fn new( 931 | words: Words, 932 | settings: Settings, 933 | stats: Statistics, 934 | today: Day, 935 | working_time: f64, 936 | rng: &mut Rand, 937 | ) -> Self { 938 | let learn_window = LearnWordsWindow::new(&words, today, &settings.type_count, rng); 939 | let known_words = words.calculate_known_words(); 940 | 941 | let mut result = Self { 942 | words, 943 | settings, 944 | stats, 945 | 946 | known_words, 947 | learn_window, 948 | load_text_window: Default::default(), 949 | add_words_window: Default::default(), 950 | add_custom_words_window: Default::default(), 951 | 952 | full_stats_window: Default::default(), 953 | percentage_graph_window: Default::default(), 954 | github_activity_window: Default::default(), 955 | 956 | import_window: Default::default(), 957 | export_window: Default::default(), 958 | settings_window: Default::default(), 959 | about_window: Default::default(), 960 | search_words_window: Default::default(), 961 | edit_word_window: Default::default(), 962 | info_window: Default::default(), 963 | synchronous_subtitles_window: Default::default(), 964 | }; 965 | 966 | result.open_activity(today, working_time); 967 | 968 | result 969 | } 970 | 971 | pub fn get_settings(&self) -> &Settings { 972 | &self.settings 973 | } 974 | 975 | pub fn save_to_string(&mut self, today: Day, working_time: f64) -> String { 976 | self.update_day_statistics(today, working_time); 977 | ron::to_string(&(&self.words, &self.settings, &self.stats)).unwrap() 978 | } 979 | 980 | pub fn save(&mut self, today: Day, working_time: f64) { 981 | quad_storage::STORAGE.lock().unwrap().set( 982 | "learn_words_data", 983 | &self.save_to_string(today, working_time), 984 | ); 985 | } 986 | 987 | pub fn load() -> (Words, Settings, Statistics) { 988 | quad_storage::STORAGE 989 | .lock() 990 | .unwrap() 991 | .get("learn_words_data") 992 | .map(|x| Self::load_from_string(&x).unwrap()) 993 | .unwrap_or_default() 994 | } 995 | 996 | pub fn load_from_string(s: &str) -> Result<(Words, Settings, Statistics), ron::Error> { 997 | ron::from_str::<(Words, Settings, Statistics)>(s) 998 | } 999 | 1000 | pub fn update_day_statistics(&mut self, today: Day, working_time: f64) { 1001 | let today = &mut self.stats.by_day.entry(today).or_default(); 1002 | today.working_time = working_time; 1003 | today.word_count_by_level = self.words.calculate_word_statistics(); 1004 | } 1005 | 1006 | pub fn open_activity(&mut self, today: Day, working_time: f64) { 1007 | self.update_day_statistics(today, working_time); 1008 | self.github_activity_window = 1009 | ClosableWindow::new(GithubActivityWindow::new(&self.stats, today)); 1010 | } 1011 | 1012 | pub fn ui( 1013 | &mut self, 1014 | ctx: &CtxRef, 1015 | today: Day, 1016 | working_time: &mut f64, 1017 | rng: &mut Rand, 1018 | paused: bool, 1019 | ) { 1020 | TopBottomPanel::top("top").show(ctx, |ui| { 1021 | menu::bar(ui, |ui| { 1022 | menu::menu(ui, "Data", |ui| { 1023 | if ui.button("Export").clicked() { 1024 | self.export_window = ClosableWindow::new(ExportWindow::new( 1025 | self.save_to_string(today, *working_time), 1026 | )); 1027 | } 1028 | if ui.button("Import").clicked() { 1029 | self.import_window = ClosableWindow::new(ImportWindow::new()); 1030 | } 1031 | }); 1032 | menu::menu(ui, "Add words", |ui| { 1033 | if ui.button("From text").clicked() { 1034 | self.load_text_window = ClosableWindow::new(LoadTextWindow::new(false)); 1035 | } 1036 | if ui.button("From subtitles").clicked() { 1037 | self.load_text_window = ClosableWindow::new(LoadTextWindow::new(true)); 1038 | } 1039 | if ui.button("Manually").clicked() { 1040 | self.add_custom_words_window = ClosableWindow::new(Default::default()); 1041 | } 1042 | ui.separator(); 1043 | if ui.button("Synchronous subtitles").clicked() { 1044 | self.synchronous_subtitles_window = 1045 | ClosableWindow::new(SynchronousSubtitlesWindow::new()); 1046 | } 1047 | }); 1048 | if ui.button("Search").clicked() { 1049 | self.search_words_window = 1050 | ClosableWindow::new(SearchWordsWindow::new(String::new(), &self.words)); 1051 | } 1052 | menu::menu(ui, "Statistics", |ui| { 1053 | if ui.button("Full").clicked() { 1054 | self.full_stats_window = ClosableWindow::new(FullStatsWindow { 1055 | time: self 1056 | .stats 1057 | .by_day 1058 | .values() 1059 | .map(|x| x.working_time) 1060 | .sum::(), 1061 | attempts: self.words.calculate_attempts_statistics(), 1062 | word_count_by_level: self.words.calculate_word_statistics(), 1063 | }); 1064 | } 1065 | if ui.button("GitHub-like").clicked() { 1066 | self.open_activity(today, *working_time); 1067 | } 1068 | ui.separator(); 1069 | if ui.button("Attempts by day").clicked() { 1070 | self.update_day_statistics(today, *working_time); 1071 | self.percentage_graph_window = 1072 | ClosableWindow::new(PercentageGraphWindow { 1073 | name: "Attempts by day", 1074 | values: self 1075 | .stats 1076 | .by_day 1077 | .iter() 1078 | .map(|(k, v)| { 1079 | ( 1080 | *k, 1081 | vec![ 1082 | v.attempts.right as f64, 1083 | v.attempts.wrong as f64, 1084 | ], 1085 | ) 1086 | }) 1087 | .collect(), 1088 | names: vec![ 1089 | "Right attempts".to_string(), 1090 | "Wrong attempts".to_string(), 1091 | ], 1092 | stackplot: false, 1093 | moving: false, 1094 | }); 1095 | } 1096 | if ui.button("Time by day").clicked() { 1097 | self.update_day_statistics(today, *working_time); 1098 | self.percentage_graph_window = 1099 | ClosableWindow::new(PercentageGraphWindow { 1100 | name: "Time by day", 1101 | values: self 1102 | .stats 1103 | .by_day 1104 | .iter() 1105 | .map(|(k, v)| (*k, vec![v.working_time])) 1106 | .collect(), 1107 | names: vec!["Working time".to_string()], 1108 | stackplot: false, 1109 | moving: false, 1110 | }); 1111 | } 1112 | if ui.button("Words by day").clicked() { 1113 | self.update_day_statistics(today, *working_time); 1114 | let available_types: BTreeSet = self 1115 | .stats 1116 | .by_day 1117 | .values() 1118 | .map(|x| x.word_count_by_level.keys().cloned()) 1119 | .flatten() 1120 | .collect(); 1121 | use WordType::*; 1122 | self.percentage_graph_window = 1123 | ClosableWindow::new(PercentageGraphWindow { 1124 | name: "Words by day", 1125 | values: self 1126 | .stats 1127 | .by_day 1128 | .iter() 1129 | .map(|(k, v)| { 1130 | ( 1131 | *k, 1132 | available_types 1133 | .iter() 1134 | .map(|x| { 1135 | v.word_count_by_level 1136 | .get(x) 1137 | .copied() 1138 | .unwrap_or(0) 1139 | as f64 1140 | }) 1141 | .collect(), 1142 | ) 1143 | }) 1144 | .collect(), 1145 | names: available_types 1146 | .iter() 1147 | .map(|x| match x { 1148 | Known => "Known".to_string(), 1149 | Trash => "Trash".to_string(), 1150 | Level(l) => format!("Level {}", l), 1151 | Learned => "Learned".to_string(), 1152 | }) 1153 | .collect(), 1154 | stackplot: false, 1155 | moving: false, 1156 | }); 1157 | } 1158 | }); 1159 | if ui.button("Settings").clicked() { 1160 | self.settings_window = 1161 | ClosableWindow::new(SettingsWindow::new(&self.settings)); 1162 | } 1163 | if ui.button("About").clicked() { 1164 | self.about_window = ClosableWindow::new(AboutWindow); 1165 | } 1166 | }); 1167 | }); 1168 | 1169 | let mut save = false; 1170 | self.learn_window.ui( 1171 | ctx, 1172 | &mut self.words, 1173 | today, 1174 | &mut self.stats.by_day.entry(today).or_default(), 1175 | &self.settings, 1176 | &mut save, 1177 | rng, 1178 | ); 1179 | if save { 1180 | self.save(today, *working_time); 1181 | } 1182 | 1183 | let window = &mut self.load_text_window; 1184 | let words = &self.words; 1185 | let add_words_window = &mut self.add_words_window; 1186 | let info_window = &mut self.info_window; 1187 | let settings = &self.settings; 1188 | window.ui(ctx, |t, ui| { 1189 | if let Some((words, stats)) = t.ui(ui, words, settings) { 1190 | if !words.words_with_context.0.is_empty() { 1191 | *add_words_window = ClosableWindow::new(AddWordsWindow::new( 1192 | words.text, 1193 | words.words_with_context, 1194 | )); 1195 | } 1196 | *info_window = ClosableWindow::new(InfoWindow(vec![ 1197 | "Info about words count in this text.".to_string(), 1198 | format!("Total: {}", words.words_count), 1199 | format!("Unique: {}", words.unique_words_count), 1200 | format!( 1201 | "Filtered: {}", 1202 | stats.filtered_known + stats.filtered_learned 1203 | ), 1204 | format!(" Known: {}", stats.filtered_known), 1205 | format!(" Learning: {}", stats.filtered_learned), 1206 | format!("Unknown: {}", stats.unknown_words), 1207 | ])); 1208 | true 1209 | } else { 1210 | false 1211 | } 1212 | }); 1213 | 1214 | let closed = self.import_window.ui(ctx, |t, ui| { 1215 | if let Some((words1, settings1, stats1)) = t.ui(ui, &self.settings) { 1216 | self.words = words1; 1217 | self.settings = settings1; 1218 | self.stats = stats1; 1219 | ui.ctx().set_pixels_per_point(self.settings.dpi); 1220 | if let Some(time) = self.stats.by_day.get(&today).map(|x| x.working_time) { 1221 | *working_time = time; 1222 | } 1223 | true 1224 | } else { 1225 | false 1226 | } 1227 | }); 1228 | if closed { 1229 | self.learn_window 1230 | .update(&self.words, today, &self.settings.type_count, rng); 1231 | } 1232 | 1233 | let mut save = false; 1234 | self.settings_window.ui(ctx, |t, ui| { 1235 | t.ui(ui, &mut self.settings, &mut save); 1236 | false 1237 | }); 1238 | if save { 1239 | self.save(today, *working_time); 1240 | } 1241 | 1242 | let mut save = false; 1243 | let closed = self.add_words_window.ui(ctx, |t, ui| { 1244 | if let Some((word, to_add, close)) = t.ui( 1245 | ui, 1246 | &mut self.search_words_window, 1247 | &mut self.synchronous_subtitles_window, 1248 | &self.words, 1249 | ) { 1250 | self.words.add_word( 1251 | word, 1252 | to_add, 1253 | today, 1254 | self.stats.by_day.entry(today).or_default(), 1255 | ); 1256 | save = true; 1257 | close 1258 | } else { 1259 | false 1260 | } 1261 | }); 1262 | if closed { 1263 | self.learn_window 1264 | .update(&self.words, today, &self.settings.type_count, rng); 1265 | self.known_words = self.words.calculate_known_words(); 1266 | self.save(today, *working_time); 1267 | } 1268 | if save { 1269 | self.save(today, *working_time); 1270 | } 1271 | 1272 | let mut save = false; 1273 | let closed = self.add_custom_words_window.ui(ctx, |t, ui| { 1274 | if let Some((word, to_add)) = t.ui(ui) { 1275 | self.words.add_word( 1276 | word, 1277 | to_add, 1278 | today, 1279 | self.stats.by_day.entry(today).or_default(), 1280 | ); 1281 | save = true; 1282 | } 1283 | false 1284 | }); 1285 | if closed { 1286 | self.learn_window 1287 | .update(&self.words, today, &self.settings.type_count, rng); 1288 | self.known_words = self.words.calculate_known_words(); 1289 | self.save(today, *working_time); 1290 | } 1291 | if save { 1292 | self.save(today, *working_time); 1293 | } 1294 | 1295 | self.full_stats_window.ui(ctx, |t, ui| { 1296 | t.ui(ui); 1297 | false 1298 | }); 1299 | 1300 | self.percentage_graph_window.ui(ctx, |t, ui| { 1301 | t.ui(ui); 1302 | false 1303 | }); 1304 | 1305 | self.github_activity_window.ui(ctx, |t, ui| { 1306 | t.ui(ui, &self.settings); 1307 | false 1308 | }); 1309 | 1310 | self.about_window.ui(ctx, |t, ui| { 1311 | t.ui(ui); 1312 | false 1313 | }); 1314 | 1315 | self.info_window.ui(ctx, |t, ui| { 1316 | t.ui(ui); 1317 | false 1318 | }); 1319 | 1320 | self.export_window.ui(ctx, |t, ui| { 1321 | t.ui(ui); 1322 | false 1323 | }); 1324 | 1325 | self.synchronous_subtitles_window.ui(ctx, |t, ui| { 1326 | t.ui(ui, &self.settings); 1327 | false 1328 | }); 1329 | 1330 | let mut edit_word = None; 1331 | self.search_words_window.ui(ctx, |t, ui| { 1332 | edit_word = t.ui(ui, &self.words); 1333 | false 1334 | }); 1335 | if let Some(edit_word) = edit_word { 1336 | self.edit_word_window = ClosableWindow::new(EditWordWindow::new(edit_word)); 1337 | } 1338 | 1339 | let mut update_search = false; 1340 | let mut save = false; 1341 | let closed = self.edit_word_window.ui(ctx, |t, ui| { 1342 | let result = t.ui(ui, &mut self.words, &mut save, &self.settings); 1343 | update_search = result.1; 1344 | result.0 1345 | }); 1346 | if update_search { 1347 | if let Some(window) = &mut self.search_words_window.0 { 1348 | window.update(&self.words); 1349 | } 1350 | } 1351 | if closed || update_search { 1352 | self.known_words = self.words.calculate_known_words(); 1353 | self.save(today, *working_time); 1354 | } 1355 | if save { 1356 | self.save(today, *working_time); 1357 | } 1358 | 1359 | egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| { 1360 | let today = &self.stats.by_day.entry(today).or_default(); 1361 | ui.monospace(format!( 1362 | "Working time: {:6} | Attempts: {:4} | New words: {:4}{}", 1363 | print_time(*working_time), 1364 | today.attempts.right + today.attempts.wrong, 1365 | today.new_unknown_words_count, 1366 | if paused { "| PAUSED" } else { "" } 1367 | )); 1368 | }); 1369 | } 1370 | } 1371 | 1372 | fn print_time(time: f64) -> String { 1373 | if time > 3600. { 1374 | format!( 1375 | "{}:{:02}:{:02}", 1376 | time as u32 / 3600, 1377 | time as u32 % 3600 / 60, 1378 | time as u32 % 60 1379 | ) 1380 | } else if time > 60. { 1381 | format!("{:02}:{:02}", time as u32 / 60, time as u32 % 60) 1382 | } else { 1383 | format!("{:02}", time as u32) 1384 | } 1385 | } 1386 | 1387 | struct LoadTextWindow { 1388 | load_subtitles: bool, 1389 | subtitles_error: Option, 1390 | text: String, 1391 | } 1392 | 1393 | impl WindowTrait for LoadTextWindow { 1394 | fn create_window(&self) -> Window<'static> { 1395 | Window::new(if self.load_subtitles { 1396 | "Words from subs" 1397 | } else { 1398 | "Words from text" 1399 | }) 1400 | .vscroll(true) 1401 | .fixed_size((200., 200.)) 1402 | .collapsible(false) 1403 | } 1404 | } 1405 | 1406 | #[derive(Default)] 1407 | struct LoadTextStats { 1408 | filtered_known: usize, 1409 | filtered_learned: usize, 1410 | unknown_words: usize, 1411 | } 1412 | 1413 | impl LoadTextWindow { 1414 | fn new(load_subtitles: bool) -> Self { 1415 | Self { 1416 | load_subtitles, 1417 | subtitles_error: None, 1418 | text: String::new(), 1419 | } 1420 | } 1421 | 1422 | fn ui( 1423 | &mut self, 1424 | ui: &mut Ui, 1425 | data: &Words, 1426 | settings: &Settings, 1427 | ) -> Option<(GetWordsResult, LoadTextStats)> { 1428 | let mut action = None; 1429 | ui.horizontal(|ui| { 1430 | if ui.button("Use this text").clicked() { 1431 | let text = &self.text; 1432 | 1433 | let words = if self.load_subtitles { 1434 | match get_words_subtitles(text) { 1435 | Ok(words) => Some(words), 1436 | Err(error) => { 1437 | self.subtitles_error = Some(format!("{:#?}", error)); 1438 | None 1439 | } 1440 | } 1441 | } else { 1442 | Some(get_words(text)) 1443 | }; 1444 | if let Some(mut words) = words { 1445 | let mut stats = LoadTextStats::default(); 1446 | words 1447 | .words_with_context 1448 | .0 1449 | .retain(|x| match data.0.get(&x.0) { 1450 | Some(x) 1451 | if x.iter() 1452 | .any(|x| matches!(x, WordStatus::ToLearn { .. })) => 1453 | { 1454 | stats.filtered_learned += 1; 1455 | false 1456 | } 1457 | Some(_) => { 1458 | stats.filtered_known += 1; 1459 | false 1460 | } 1461 | None => { 1462 | stats.unknown_words += 1; 1463 | true 1464 | } 1465 | }); 1466 | action = Some((words, stats)); 1467 | } 1468 | } 1469 | }); 1470 | if let Some(error) = &self.subtitles_error { 1471 | ui.separator(); 1472 | ui.horizontal_wrapped(|ui| { 1473 | ui.spacing_mut().item_spacing.x = 0.; 1474 | ui.add( 1475 | Label::new("Error: ") 1476 | .text_color(settings.color_error()) 1477 | .monospace(), 1478 | ); 1479 | ui.monospace(error); 1480 | }); 1481 | } 1482 | ui.separator(); 1483 | ui.text_edit_multiline(&mut self.text); 1484 | action 1485 | } 1486 | } 1487 | 1488 | struct ExportWindow { 1489 | text: String, 1490 | } 1491 | 1492 | impl WindowTrait for ExportWindow { 1493 | fn create_window(&self) -> Window<'static> { 1494 | Window::new("Export data") 1495 | .vscroll(true) 1496 | .fixed_size((200., 200.)) 1497 | .collapsible(false) 1498 | } 1499 | } 1500 | 1501 | impl ExportWindow { 1502 | fn new(text: String) -> Self { 1503 | Self { text } 1504 | } 1505 | 1506 | fn ui(&mut self, ui: &mut Ui) { 1507 | ui.label("Copy from this field: Ctrl+A, Ctrl+C."); 1508 | 1509 | #[cfg(target_arch = "wasm32")] 1510 | if ui.button("Download as file").clicked() { 1511 | download_as_file(&self.text); 1512 | } 1513 | 1514 | ui.text_edit_multiline(&mut self.text); 1515 | } 1516 | } 1517 | 1518 | struct ImportWindow { 1519 | text: String, 1520 | error: Option, 1521 | } 1522 | 1523 | impl WindowTrait for ImportWindow { 1524 | fn create_window(&self) -> Window<'static> { 1525 | Window::new("Import data") 1526 | .vscroll(true) 1527 | .fixed_size((200., 200.)) 1528 | .collapsible(false) 1529 | } 1530 | } 1531 | 1532 | impl ImportWindow { 1533 | fn new() -> Self { 1534 | Self { 1535 | text: String::new(), 1536 | error: None, 1537 | } 1538 | } 1539 | 1540 | fn ui( 1541 | &mut self, 1542 | ui: &mut Ui, 1543 | settings: &Settings, 1544 | ) -> Option<(Words, Settings, Statistics)> { 1545 | let mut action = None; 1546 | ui.horizontal(|ui| { 1547 | if ui.button("Use this text").clicked() { 1548 | match Program::load_from_string(&self.text) { 1549 | Ok(result) => action = Some(result), 1550 | Err(error) => { 1551 | self.error = Some(format!("{:#?}", error)); 1552 | } 1553 | } 1554 | } 1555 | }); 1556 | if let Some(error) = &self.error { 1557 | ui.separator(); 1558 | ui.horizontal_wrapped(|ui| { 1559 | ui.spacing_mut().item_spacing.x = 0.; 1560 | ui.add( 1561 | Label::new("Error: ") 1562 | .text_color(settings.color_error()) 1563 | .monospace(), 1564 | ); 1565 | ui.monospace(error); 1566 | }); 1567 | } 1568 | ui.separator(); 1569 | ui.text_edit_multiline(&mut self.text); 1570 | action 1571 | } 1572 | } 1573 | 1574 | struct SettingsWindow { 1575 | lang1: String, 1576 | lang2: String, 1577 | want_to_use_keyboard_layout: bool, 1578 | info: Option>, 1579 | } 1580 | 1581 | impl WindowTrait for SettingsWindow { 1582 | fn create_window(&self) -> Window<'static> { 1583 | Window::new("Settings") 1584 | .vscroll(false) 1585 | .fixed_size((300., 100.)) 1586 | .collapsible(false) 1587 | } 1588 | } 1589 | 1590 | impl SettingsWindow { 1591 | fn new(settings: &Settings) -> Self { 1592 | let mut result = Self { 1593 | lang1: String::new(), 1594 | lang2: String::new(), 1595 | want_to_use_keyboard_layout: false, 1596 | info: None, 1597 | }; 1598 | if settings.use_keyboard_layout { 1599 | result.lang1 = settings.keyboard_layout.lang1.keys().copied().collect(); 1600 | result.lang2 = settings.keyboard_layout.lang1.values().copied().collect(); 1601 | } 1602 | result 1603 | } 1604 | 1605 | fn ui(&mut self, ui: &mut Ui, settings: &mut Settings, save: &mut bool) { 1606 | ui.horizontal(|ui| { 1607 | ui.label("Theme: "); 1608 | if !settings.white_theme { 1609 | if ui 1610 | .add(Button::new("☀").frame(false)) 1611 | .on_hover_text("Switch to light mode") 1612 | .clicked() 1613 | { 1614 | settings.white_theme = !settings.white_theme; 1615 | ui.ctx().set_visuals(Visuals::light()); 1616 | *save = true; 1617 | } 1618 | } else { 1619 | if ui 1620 | .add(Button::new("🌙").frame(false)) 1621 | .on_hover_text("Switch to dark mode") 1622 | .clicked() 1623 | { 1624 | settings.white_theme = !settings.white_theme; 1625 | ui.ctx().set_visuals(Visuals::dark()); 1626 | *save = true; 1627 | } 1628 | } 1629 | }); 1630 | 1631 | ui.separator(); 1632 | 1633 | ui.horizontal(|ui| { 1634 | ui.label("Inaction time for pause: "); 1635 | if ui 1636 | .add( 1637 | egui::DragValue::new(&mut settings.time_to_pause) 1638 | .speed(0.1) 1639 | .clamp_range(0.0..=100.0) 1640 | .min_decimals(0) 1641 | .max_decimals(2), 1642 | ) 1643 | .changed() 1644 | { 1645 | *save = true; 1646 | } 1647 | }); 1648 | 1649 | ui.separator(); 1650 | 1651 | ui.horizontal(|ui| { 1652 | let scale_factor = 1.05; 1653 | ui.label(format!("Scale: {:.2}", settings.dpi)); 1654 | if ui 1655 | .add(egui::widgets::Button::new(" + ").text_style(egui::TextStyle::Monospace)) 1656 | .clicked() 1657 | { 1658 | settings.dpi *= scale_factor; 1659 | *save = true; 1660 | } 1661 | if ui 1662 | .add(egui::widgets::Button::new(" - ").text_style(egui::TextStyle::Monospace)) 1663 | .clicked() 1664 | { 1665 | settings.dpi /= scale_factor; 1666 | *save = true; 1667 | } 1668 | ui.ctx().set_pixels_per_point(settings.dpi); 1669 | }); 1670 | 1671 | ui.separator(); 1672 | 1673 | ui.collapsing("Automatic change of keyboard layout", |ui| { 1674 | if !self.want_to_use_keyboard_layout && settings.use_keyboard_layout { 1675 | self.want_to_use_keyboard_layout = true; 1676 | *save = true; 1677 | } 1678 | ui.checkbox( 1679 | &mut self.want_to_use_keyboard_layout, 1680 | "Use automatic change of keyboard layout", 1681 | ); 1682 | if self.want_to_use_keyboard_layout { 1683 | ui.separator(); 1684 | ui.label("Type all letters on your keyboard in first field, and then in the same order symbols in the second field. Newline is ignored. If you can't type some symbol, you can use space. Count of symbols except newline must be the same of both fields."); 1685 | ui.label("First language:"); 1686 | ui.text_edit_multiline(&mut self.lang1); 1687 | ui.label("Second language:"); 1688 | ui.text_edit_multiline(&mut self.lang2); 1689 | if ui.button("Use this keyboard layout").clicked() { 1690 | match KeyboardLayout::new(&self.lang1, &self.lang2) { 1691 | Ok(ok) => { 1692 | settings.use_keyboard_layout = true; 1693 | settings.keyboard_layout = ok; 1694 | self.info = Some(Ok("Used!".to_string())); 1695 | *save = true; 1696 | } 1697 | Err(err) => { 1698 | self.info = Some(Err(err)); 1699 | } 1700 | } 1701 | } 1702 | if let Some(info) = &self.info { 1703 | match info { 1704 | Ok(ok) => { 1705 | ui.label(ok); 1706 | } 1707 | Err(err) => { 1708 | ui.horizontal_wrapped(|ui| { 1709 | ui.spacing_mut().item_spacing.x = 0.; 1710 | ui.add(Label::new("Error: ").text_color(settings.color_error()).monospace()); 1711 | ui.monospace(err); 1712 | }); 1713 | } 1714 | } 1715 | } 1716 | } else { 1717 | settings.use_keyboard_layout = false; 1718 | } 1719 | }); 1720 | 1721 | ui.separator(); 1722 | 1723 | ui.collapsing("Repeats", |ui| { 1724 | let mut delete = None; 1725 | let color_delete = settings.color_delete(); 1726 | let color_add = settings.color_add(); 1727 | for (pos, i) in settings.type_count.iter_mut().enumerate() { 1728 | ui.horizontal(|ui| { 1729 | ui.label(format!("{}.", pos)); 1730 | ui.separator(); 1731 | ui.label("Wait days: "); 1732 | if ui 1733 | .add( 1734 | egui::DragValue::new(&mut i.wait_days) 1735 | .speed(0.1) 1736 | .clamp_range(0.0..=99.0) 1737 | .min_decimals(0) 1738 | .max_decimals(0), 1739 | ) 1740 | .changed() 1741 | { 1742 | *save = true; 1743 | } 1744 | ui.separator(); 1745 | ui.label("Count: "); 1746 | if ui 1747 | .add( 1748 | egui::DragValue::new(&mut i.count) 1749 | .speed(0.1) 1750 | .clamp_range(0.0..=99.0) 1751 | .min_decimals(0) 1752 | .max_decimals(0), 1753 | ) 1754 | .changed() 1755 | { 1756 | *save = true; 1757 | } 1758 | ui.separator(); 1759 | ui.checkbox(&mut i.show_word, "Show hint"); 1760 | ui.separator(); 1761 | if ui 1762 | .add(Button::new("Delete").text_color(color_delete)) 1763 | .clicked() 1764 | { 1765 | delete = Some(pos); 1766 | } 1767 | }); 1768 | } 1769 | ui.separator(); 1770 | if ui.add(Button::new("Add").text_color(color_add)).clicked() { 1771 | settings.type_count.push(LearnType { 1772 | wait_days: 0, 1773 | count: 1, 1774 | show_word: false, 1775 | }); 1776 | *save = true; 1777 | } 1778 | if let Some(pos) = delete { 1779 | settings.type_count.remove(pos); 1780 | *save = true; 1781 | } 1782 | }); 1783 | } 1784 | } 1785 | 1786 | struct InfoWindow(Vec); 1787 | 1788 | impl WindowTrait for InfoWindow { 1789 | fn create_window(&self) -> Window<'static> { 1790 | Window::new("Info") 1791 | .vscroll(false) 1792 | .fixed_size((200., 50.)) 1793 | .collapsible(false) 1794 | } 1795 | } 1796 | 1797 | impl InfoWindow { 1798 | fn ui(&mut self, ui: &mut Ui) { 1799 | for i in &self.0 { 1800 | ui.label(i); 1801 | } 1802 | } 1803 | } 1804 | 1805 | struct AboutWindow; 1806 | 1807 | impl WindowTrait for AboutWindow { 1808 | fn create_window(&self) -> Window<'static> { 1809 | Window::new("About") 1810 | .vscroll(false) 1811 | .fixed_size((320., 100.)) 1812 | .collapsible(false) 1813 | } 1814 | } 1815 | 1816 | impl AboutWindow { 1817 | fn ui(&mut self, ui: &mut Ui) { 1818 | ui.heading("Learn Words"); 1819 | ui.separator(); 1820 | ui.label("This is the program to learn words in foreign languages."); 1821 | ui.separator(); 1822 | ui.horizontal_wrapped(|ui| { 1823 | ui.spacing_mut().item_spacing.x = 0.; 1824 | ui.add(egui::Label::new("Version: ").strong()); 1825 | ui.label(env!("CARGO_PKG_VERSION")); 1826 | }); 1827 | ui.horizontal_wrapped(|ui| { 1828 | ui.spacing_mut().item_spacing.x = 0.; 1829 | ui.add(egui::Label::new("Author: ").strong()); 1830 | ui.label("Ilya Sheprut"); 1831 | }); 1832 | ui.horizontal_wrapped(|ui| { 1833 | ui.spacing_mut().item_spacing.x = 0.; 1834 | ui.add(egui::Label::new("License: ").strong()); 1835 | ui.label("MIT or Apache 2.0"); 1836 | }); 1837 | ui.horizontal_wrapped(|ui| { 1838 | ui.spacing_mut().item_spacing.x = 0.; 1839 | ui.add(egui::Label::new("Repository: ").strong()); 1840 | ui.hyperlink("https://github.com/optozorax/learn_words"); 1841 | }); 1842 | } 1843 | } 1844 | 1845 | struct SearchWordsWindow { 1846 | search_string: String, 1847 | found_variants: Vec, 1848 | show_inners: bool, 1849 | } 1850 | 1851 | impl WindowTrait for SearchWordsWindow { 1852 | fn create_window(&self) -> Window<'static> { 1853 | Window::new("Search words") 1854 | .vscroll(false) 1855 | .fixed_size((200., 300.)) 1856 | .collapsible(false) 1857 | } 1858 | } 1859 | 1860 | impl SearchWordsWindow { 1861 | fn new(search_string: String, words: &Words) -> Self { 1862 | let mut result = Self { 1863 | search_string, 1864 | found_variants: Vec::new(), 1865 | show_inners: false, 1866 | }; 1867 | result.update(words); 1868 | result 1869 | } 1870 | 1871 | fn update_new(&mut self, search_string: String, words: &Words) { 1872 | if search_string != self.search_string { 1873 | self.search_string = search_string; 1874 | self.update(words); 1875 | } 1876 | } 1877 | 1878 | fn update(&mut self, words: &Words) { 1879 | const ACCEPTED_LEVENSHTEIN: usize = 4; 1880 | let mut results = Vec::new(); 1881 | for word in words.0.keys() { 1882 | let levenshtein = strsim::levenshtein(word, &self.search_string); 1883 | if levenshtein < ACCEPTED_LEVENSHTEIN { 1884 | let jaro = strsim::jaro(word, &self.search_string); 1885 | results.push((levenshtein, jaro, word.clone())); 1886 | } 1887 | } 1888 | results.sort_by(|a, b| { 1889 | if a.0 == b.0 { 1890 | a.1.partial_cmp(&b.1).unwrap() 1891 | } else { 1892 | a.0.cmp(&b.0) 1893 | } 1894 | }); 1895 | self.found_variants = results.into_iter().map(|(_, _, w)| w).collect(); 1896 | } 1897 | 1898 | fn find_word(this: &mut Option, search_string: String, words: &Words) { 1899 | if let Some(window) = this { 1900 | window.update_new(search_string, words); 1901 | } else { 1902 | *this = Some(Self::new(search_string, words)); 1903 | } 1904 | } 1905 | 1906 | fn ui(&mut self, ui: &mut Ui, words: &Words) -> Option { 1907 | if ui 1908 | .add( 1909 | TextEdit::singleline(&mut self.search_string) 1910 | .hint_text("Type here to find word..."), 1911 | ) 1912 | .changed() 1913 | { 1914 | self.update(words); 1915 | } 1916 | ui.checkbox(&mut self.show_inners, "Show inners"); 1917 | ui.separator(); 1918 | let mut edit_word = None; 1919 | ScrollArea::vertical().max_height(200.0).show(ui, |ui| { 1920 | if self.search_string.is_empty() { 1921 | if self.show_inners { 1922 | for (n, (word, translations)) in words.0.iter().enumerate() { 1923 | ui.with_layout(Layout::right_to_left(), |ui| { 1924 | if ui.button("✏").on_hover_text("Edit").clicked() { 1925 | edit_word = Some(word.clone()); 1926 | } 1927 | ui.with_layout(Layout::left_to_right(), |ui| { 1928 | ui.heading(format!("{}. {}", n, word)); 1929 | }); 1930 | }); 1931 | for word_status in translations { 1932 | ui.allocate_space(egui::vec2(1.0, 5.0)); 1933 | word_status_show_ui(word_status, ui); 1934 | } 1935 | ui.separator(); 1936 | } 1937 | } else { 1938 | for (n, word) in words.0.keys().enumerate() { 1939 | ui.with_layout(Layout::right_to_left(), |ui| { 1940 | if ui.button("✏").on_hover_text("Edit").clicked() { 1941 | edit_word = Some(word.clone()); 1942 | } 1943 | ui.with_layout(Layout::left_to_right(), |ui| { 1944 | ui.label(format!("{}. {}", n, word)); 1945 | }); 1946 | }); 1947 | } 1948 | } 1949 | } else if self.show_inners { 1950 | for (word, translations) in self 1951 | .found_variants 1952 | .iter() 1953 | .map(|x| (x, words.0.get(x).unwrap())) 1954 | { 1955 | ui.with_layout(Layout::right_to_left(), |ui| { 1956 | if ui.button("✏").on_hover_text("Edit").clicked() { 1957 | edit_word = Some(word.clone()); 1958 | } 1959 | ui.with_layout(Layout::left_to_right(), |ui| { 1960 | ui.heading(word); 1961 | }); 1962 | }); 1963 | for word_status in translations { 1964 | ui.allocate_space(egui::vec2(1.0, 5.0)); 1965 | word_status_show_ui(word_status, ui); 1966 | } 1967 | ui.separator(); 1968 | } 1969 | } else { 1970 | for word in &self.found_variants { 1971 | ui.with_layout(Layout::right_to_left(), |ui| { 1972 | if ui.button("✏").on_hover_text("Edit").clicked() { 1973 | edit_word = Some(word.clone()); 1974 | } 1975 | ui.with_layout(Layout::left_to_right(), |ui| { 1976 | ui.label(word); 1977 | }); 1978 | }); 1979 | } 1980 | } 1981 | }); 1982 | edit_word 1983 | } 1984 | } 1985 | 1986 | struct EditWordWindow { 1987 | word: String, 1988 | word_to_edit: String, 1989 | } 1990 | 1991 | impl WindowTrait for EditWordWindow { 1992 | fn create_window(&self) -> Window<'static> { 1993 | Window::new("Edit word") 1994 | .vscroll(true) 1995 | .fixed_size((200., 300.)) 1996 | .collapsible(false) 1997 | } 1998 | } 1999 | 2000 | impl EditWordWindow { 2001 | fn new(word: String) -> Self { 2002 | Self { 2003 | word: word.clone(), 2004 | word_to_edit: word, 2005 | } 2006 | } 2007 | 2008 | fn ui( 2009 | &mut self, 2010 | ui: &mut Ui, 2011 | words: &mut Words, 2012 | save: &mut bool, 2013 | settings: &Settings, 2014 | ) -> (bool, bool) { 2015 | ui.label("Please not edit words while typing in learning words window!"); 2016 | if let Some(getted) = words.0.get_mut(&self.word) { 2017 | let mut remove_word = false; 2018 | ui.with_layout(Layout::right_to_left(), |ui| { 2019 | if ui 2020 | .add(Button::new("Delete").text_color(settings.color_delete())) 2021 | .clicked() 2022 | { 2023 | remove_word = true; 2024 | *save = true; 2025 | } 2026 | ui.with_layout(Layout::left_to_right(), |ui| { 2027 | if ui.text_edit_singleline(&mut self.word_to_edit).changed() { 2028 | *save = true; 2029 | } 2030 | }); 2031 | }); 2032 | let mut rename = None; 2033 | let mut delete = None; 2034 | for (pos, word) in getted.iter_mut().enumerate() { 2035 | ui.separator(); 2036 | let mut is_delete = false; 2037 | if word_status_edit_ui(word, ui, &mut rename, &mut is_delete, settings) { 2038 | *save = true; 2039 | } 2040 | if is_delete { 2041 | delete = Some(pos); 2042 | } 2043 | } 2044 | ui.separator(); 2045 | if ui 2046 | .add(Button::new("Add").text_color(settings.color_add())) 2047 | .clicked() 2048 | { 2049 | getted.push(WordStatus::KnowPreviously); 2050 | } 2051 | if let Some(pos) = delete { 2052 | getted.remove(pos); 2053 | if getted.is_empty() { 2054 | remove_word = true; 2055 | } 2056 | } 2057 | if let Some((previous, new)) = rename { 2058 | words.rename_word(&previous, &new); 2059 | } 2060 | if self.word_to_edit != self.word { 2061 | words.rename_word(&self.word, &self.word_to_edit); 2062 | self.word = self.word_to_edit.clone(); 2063 | return (false, true); 2064 | } 2065 | if remove_word { 2066 | words.remove_word(&self.word); 2067 | return (true, true); 2068 | } 2069 | (false, false) 2070 | } else { 2071 | (true, true) 2072 | } 2073 | } 2074 | } 2075 | 2076 | struct AddWordsWindow { 2077 | text: String, 2078 | words: WordsWithContext, 2079 | translations: String, 2080 | known_translations: String, 2081 | previous: Option<(String, Vec>)>, 2082 | } 2083 | 2084 | impl WindowTrait for AddWordsWindow { 2085 | fn create_window(&self) -> Window<'static> { 2086 | Window::new("Add words") 2087 | .vscroll(false) 2088 | .fixed_size((400., 400.)) 2089 | .collapsible(false) 2090 | } 2091 | } 2092 | 2093 | impl AddWordsWindow { 2094 | fn new(text: String, words: WordsWithContext) -> Self { 2095 | AddWordsWindow { 2096 | text, 2097 | words, 2098 | translations: String::new(), 2099 | known_translations: String::new(), 2100 | previous: None, 2101 | } 2102 | } 2103 | 2104 | fn ui( 2105 | &mut self, 2106 | ui: &mut Ui, 2107 | search_words_window: &mut ClosableWindow, 2108 | synchronous_subtitles_window: &mut ClosableWindow, 2109 | words: &Words, 2110 | ) -> Option<(String, WordsToAdd, bool)> { 2111 | ui.columns(2, |cols| { 2112 | let ui = &mut cols[0]; 2113 | let mut action = None; 2114 | ui.label(format!("Words remains: {}", self.words.0.len())); 2115 | ui.label(format!("Occurences in text: {}", self.words.0[0].1.len())); 2116 | SearchWordsWindow::find_word( 2117 | &mut search_words_window.0, 2118 | self.words.0[0].0.clone(), 2119 | words, 2120 | ); 2121 | SynchronousSubtitlesWindow::change_search_string( 2122 | &mut synchronous_subtitles_window.0, 2123 | self.words.0[0].0.clone(), 2124 | true, 2125 | ); 2126 | ui.separator(); 2127 | ui.horizontal(|ui| { 2128 | if ui.button("Skip").clicked() { 2129 | self.translations.clear(); 2130 | self.known_translations.clear(); 2131 | self.previous = Some(self.words.0.remove(0)); 2132 | } 2133 | if let Some((text, ranges)) = &self.previous { 2134 | if ui.button(format!("Return ({})", text)).clicked() { 2135 | self.words.0.insert(0, (text.clone(), ranges.clone())); 2136 | self.previous = None; 2137 | } 2138 | } else { 2139 | ui.add_enabled(false, Button::new("Return previous")); 2140 | } 2141 | }); 2142 | if let Some((word, to_add)) = word_to_add( 2143 | ui, 2144 | &mut self.words.0[0].0, 2145 | &mut self.translations, 2146 | &mut self.known_translations, 2147 | ) { 2148 | self.translations.clear(); 2149 | self.known_translations.clear(); 2150 | self.previous = Some(self.words.0.remove(0)); 2151 | action = Some((word, to_add, self.words.0.is_empty())); 2152 | } 2153 | 2154 | let ui = &mut cols[1]; 2155 | ui.label("Context:"); 2156 | ui.separator(); 2157 | if self.words.0.is_empty() { 2158 | return action; 2159 | } 2160 | ScrollArea::vertical().max_height(200.0).show(ui, |ui| { 2161 | const CONTEXT_SIZE: usize = 50; 2162 | for range in &self.words.0[0].1 { 2163 | let mut start = range.start.saturating_sub(CONTEXT_SIZE); 2164 | let mut end = { 2165 | let result = range.end + CONTEXT_SIZE; 2166 | if result > self.text.len() { 2167 | self.text.len() 2168 | } else { 2169 | result 2170 | } 2171 | }; 2172 | while start > 0 && !self.text.is_char_boundary(start) { 2173 | start -= 1; 2174 | } 2175 | while end < self.text.len() && !self.text.is_char_boundary(end) { 2176 | end += 1; 2177 | } 2178 | ui.horizontal_wrapped(|ui| { 2179 | ui.spacing_mut().item_spacing.x = 0.; 2180 | ui.label("..."); 2181 | ui.label(&self.text[start..range.start]); 2182 | ui.add(egui::Label::new(&self.text[range.clone()]).strong()); 2183 | ui.label(&self.text[range.end..end]); 2184 | ui.label("..."); 2185 | }); 2186 | 2187 | ui.separator(); 2188 | } 2189 | }); 2190 | 2191 | action 2192 | }) 2193 | } 2194 | } 2195 | 2196 | #[derive(Default)] 2197 | struct AddCustomWordsWindow { 2198 | word: String, 2199 | translations: String, 2200 | known_translations: String, 2201 | } 2202 | 2203 | impl WindowTrait for AddCustomWordsWindow { 2204 | fn create_window(&self) -> Window<'static> { 2205 | Window::new("Add words") 2206 | .vscroll(false) 2207 | .fixed_size((200., 100.)) 2208 | .collapsible(false) 2209 | } 2210 | } 2211 | 2212 | impl AddCustomWordsWindow { 2213 | fn ui(&mut self, ui: &mut Ui) -> Option<(String, WordsToAdd)> { 2214 | let mut action = None; 2215 | ui.separator(); 2216 | if let Some((word, to_add)) = word_to_add( 2217 | ui, 2218 | &mut self.word, 2219 | &mut self.translations, 2220 | &mut self.known_translations, 2221 | ) { 2222 | self.translations.clear(); 2223 | self.known_translations.clear(); 2224 | self.word.clear(); 2225 | action = Some((word, to_add)); 2226 | } 2227 | action 2228 | } 2229 | } 2230 | 2231 | #[derive(Default)] 2232 | struct FullStatsWindow { 2233 | time: f64, 2234 | attempts: TypingStats, 2235 | word_count_by_level: BTreeMap, 2236 | } 2237 | 2238 | impl WindowTrait for FullStatsWindow { 2239 | fn create_window(&self) -> Window<'static> { 2240 | Window::new("Full statistics") 2241 | .vscroll(false) 2242 | .fixed_size((150., 100.)) 2243 | .collapsible(false) 2244 | } 2245 | } 2246 | 2247 | impl FullStatsWindow { 2248 | fn ui(&mut self, ui: &mut Ui) { 2249 | ui.label(format!("Full working time: {}", print_time(self.time))); 2250 | ui.separator(); 2251 | ui.label(format!( 2252 | "Attempts: {}", 2253 | self.attempts.right + self.attempts.wrong, 2254 | )); 2255 | ui.label(format!("Correct: {}", self.attempts.right,)); 2256 | ui.label(format!("Wrong: {}", self.attempts.wrong,)); 2257 | ui.separator(); 2258 | ui.label("Count of words:"); 2259 | for (kind, count) in &self.word_count_by_level { 2260 | use WordType::*; 2261 | match kind { 2262 | Known => ui.label(format!("Known: {}", count)), 2263 | Trash => ui.label(format!("Trash: {}", count)), 2264 | Level(l) => ui.label(format!("Level {}: {}", l, count)), 2265 | Learned => ui.label(format!("Learned: {}", count)), 2266 | }; 2267 | } 2268 | } 2269 | } 2270 | 2271 | #[derive(Default)] 2272 | struct PercentageGraphWindow { 2273 | name: &'static str, 2274 | values: BTreeMap>, 2275 | names: Vec, 2276 | stackplot: bool, 2277 | moving: bool, 2278 | } 2279 | 2280 | impl WindowTrait for PercentageGraphWindow { 2281 | fn create_window(&self) -> Window<'static> { 2282 | Window::new(self.name).vscroll(false).collapsible(false) 2283 | } 2284 | } 2285 | 2286 | impl PercentageGraphWindow { 2287 | fn ui(&mut self, ui: &mut Ui) { 2288 | ui.horizontal(|ui| { 2289 | ui.checkbox(&mut self.stackplot, "Stackplot"); 2290 | ui.checkbox(&mut self.moving, "Enable moving"); 2291 | }); 2292 | use egui::plot::*; 2293 | let mut max_value = 0.; 2294 | let lines = (0..self.values.values().next().unwrap().len()).map(|i| { 2295 | Line::new(Values::from_values( 2296 | self.values 2297 | .iter() 2298 | .map(|(day, arr)| { 2299 | let value = if self.stackplot { 2300 | arr.iter().take(i + 1).sum::() 2301 | } else { 2302 | arr[i] 2303 | }; 2304 | if value > max_value { 2305 | max_value = value; 2306 | } 2307 | Value::new(day.0 as f64, value) 2308 | }) 2309 | .collect(), 2310 | )) 2311 | }); 2312 | 2313 | let mut plot = Plot::new(format!("percentage {}", self.moving)) 2314 | .allow_zoom(self.moving) 2315 | .allow_drag(self.moving) 2316 | .legend(Legend::default().position(Corner::LeftTop)); 2317 | for (line, name) in lines.zip(self.names.iter()) { 2318 | plot = plot.line(line.name(name)); 2319 | } 2320 | 2321 | let min_day = self.values.keys().next().unwrap().0 as f64; 2322 | let max_day = self.values.keys().rev().next().unwrap().0 as f64; 2323 | plot = plot.polygon( 2324 | Polygon::new(Values::from_values(vec![ 2325 | Value::new(min_day, 0.), 2326 | Value::new(max_day, 0.), 2327 | Value::new(max_day, max_value), 2328 | Value::new(min_day, max_value), 2329 | ])) 2330 | .width(0.) 2331 | .fill_alpha(0.005), 2332 | ); 2333 | ui.add(plot); 2334 | } 2335 | } 2336 | 2337 | enum SynchronousSubtitlesWindow { 2338 | Load { 2339 | lang1: String, 2340 | lang2: String, 2341 | error1: Option, 2342 | error2: Option, 2343 | }, 2344 | View { 2345 | search_string: String, 2346 | whole_words_search: bool, 2347 | found: Vec, 2348 | position: usize, 2349 | phrases: Vec<(Option, Option)>, 2350 | update_scroll: bool, 2351 | }, 2352 | } 2353 | 2354 | impl WindowTrait for SynchronousSubtitlesWindow { 2355 | fn create_window(&self) -> Window<'static> { 2356 | if matches!(self, SynchronousSubtitlesWindow::Load { .. }) { 2357 | Window::new("Load synchronous subtitles") 2358 | .vscroll(true) 2359 | .fixed_size((300., 200.)) 2360 | .collapsible(false) 2361 | } else { 2362 | Window::new("View synchronous subtitles") 2363 | .vscroll(false) 2364 | .fixed_size((400., 200.)) 2365 | .collapsible(false) 2366 | } 2367 | } 2368 | } 2369 | 2370 | impl SynchronousSubtitlesWindow { 2371 | fn new() -> Self { 2372 | Self::Load { 2373 | lang1: String::new(), 2374 | lang2: String::new(), 2375 | error1: None, 2376 | error2: None, 2377 | } 2378 | } 2379 | 2380 | fn calc_phrases( 2381 | sub1: Vec, 2382 | sub2: Vec, 2383 | ) -> Vec<(Option, Option)> { 2384 | use std::ops::RangeInclusive; 2385 | 2386 | fn convert_time(time: srtparse::Time) -> u64 { 2387 | time.milliseconds + 1000 * (time.seconds + 60 * (time.minutes + 60 * time.hours)) 2388 | } 2389 | 2390 | fn convert(item: srtparse::Item) -> (RangeInclusive, String, bool) { 2391 | let start = convert_time(item.start_time); 2392 | let end = convert_time(item.end_time); 2393 | (start..=end, item.text, false) // false means 'used' 2394 | } 2395 | 2396 | let mut sub1: Vec<_> = sub1.into_iter().map(convert).collect(); 2397 | let mut sub2: Vec<_> = sub2.into_iter().map(convert).collect(); 2398 | let mut result = Vec::new(); 2399 | 2400 | let end_times = { 2401 | let mut result = sub1 2402 | .iter() 2403 | .enumerate() 2404 | .map(|(pos, x)| (pos, *x.0.end(), false)) 2405 | .chain( 2406 | sub2.iter() 2407 | .enumerate() 2408 | .map(|(pos, x)| (pos, *x.0.end(), true)), 2409 | ) 2410 | .collect::>(); 2411 | result.sort_by_key(|x| x.1); 2412 | result 2413 | }; 2414 | 2415 | for (pos, end, is_second) in end_times { 2416 | #[rustfmt::skip] 2417 | macro_rules! current { () => { if is_second { &mut sub2 } else { &mut sub1 } }; } 2418 | #[rustfmt::skip] 2419 | macro_rules! other { () => { if is_second { &mut sub1 } else { &mut sub2 } }; } 2420 | if !current!()[pos].2 { 2421 | current!()[pos].2 = true; 2422 | if let Some(pos1) = other!().iter().position(|x| x.0.contains(&end) && !x.2) { 2423 | other!()[pos1].2 = true; 2424 | if is_second { 2425 | result.push((Some(sub1[pos1].1.clone()), Some(sub2[pos].1.clone()))); 2426 | } else { 2427 | result.push((Some(sub1[pos].1.clone()), Some(sub2[pos1].1.clone()))); 2428 | } 2429 | } else { 2430 | if is_second { 2431 | result.push((None, Some(sub2[pos].1.clone()))); 2432 | } else { 2433 | result.push((Some(sub1[pos].1.clone()), None)); 2434 | } 2435 | } 2436 | } 2437 | } 2438 | 2439 | result 2440 | } 2441 | 2442 | fn change_search_string( 2443 | this: &mut Option, 2444 | search_string1: String, 2445 | whole_words_search1: bool, 2446 | ) { 2447 | use SynchronousSubtitlesWindow::*; 2448 | if let Some(this) = this { 2449 | let update = if let View { 2450 | search_string, 2451 | whole_words_search, 2452 | .. 2453 | } = this 2454 | { 2455 | if *search_string != search_string1 { 2456 | *search_string = search_string1; 2457 | *whole_words_search = whole_words_search1; 2458 | true 2459 | } else { 2460 | false 2461 | } 2462 | } else { 2463 | false 2464 | }; 2465 | if update { 2466 | this.update(); 2467 | if let View { 2468 | position, 2469 | found, 2470 | update_scroll, 2471 | .. 2472 | } = this 2473 | { 2474 | if found.len() > 1 { 2475 | *position = 1; 2476 | *update_scroll = true; 2477 | } 2478 | } 2479 | } 2480 | } 2481 | } 2482 | 2483 | fn find_whole_word_bool(text: &str, word: &str) -> bool { 2484 | let word = word.chars().collect::>(); 2485 | #[derive(Clone)] 2486 | enum State { 2487 | NotAlphabetic, 2488 | SkipCurrentWord, 2489 | Check(usize), 2490 | } 2491 | use State::*; 2492 | let mut state = NotAlphabetic; 2493 | for c in text.chars().chain(std::iter::once('.')) { 2494 | loop { 2495 | let mut to_break = true; 2496 | match state.clone() { 2497 | NotAlphabetic => { 2498 | if is_word_symbol(c) { 2499 | state = Check(0); 2500 | to_break = false; 2501 | } else { 2502 | // do nothing 2503 | } 2504 | } 2505 | SkipCurrentWord => { 2506 | if is_word_symbol(c) { 2507 | // do nothing 2508 | } else { 2509 | state = NotAlphabetic; 2510 | } 2511 | } 2512 | Check(pos) => { 2513 | if is_word_symbol(c) { 2514 | #[allow(clippy::collapsible_else_if)] 2515 | if pos == word.len() { 2516 | state = SkipCurrentWord; 2517 | } else { 2518 | // todo сделать нормально 2519 | if c.to_lowercase().next().unwrap() == word[pos] { 2520 | state = Check(pos + 1); 2521 | } else { 2522 | state = SkipCurrentWord; 2523 | } 2524 | } 2525 | } else { 2526 | if pos == word.len() { 2527 | return true; 2528 | } 2529 | state = NotAlphabetic; 2530 | } 2531 | } 2532 | } 2533 | if to_break { 2534 | break; 2535 | } 2536 | } 2537 | } 2538 | false 2539 | } 2540 | 2541 | fn find_occurence_bool(text: &str, occurence: &str) -> bool { 2542 | text.contains(occurence) 2543 | } 2544 | 2545 | fn update(&mut self) { 2546 | use SynchronousSubtitlesWindow::*; 2547 | if let View { 2548 | search_string, 2549 | whole_words_search, 2550 | found, 2551 | position, 2552 | phrases, 2553 | update_scroll, 2554 | } = self 2555 | { 2556 | *position = 0; 2557 | found.clear(); 2558 | found.push(0); 2559 | if !search_string.is_empty() { 2560 | for (pos, text) in phrases 2561 | .iter() 2562 | .enumerate() 2563 | .filter_map(|(pos, x)| Some((pos, x.0.as_ref()?))) 2564 | { 2565 | let find_result = if *whole_words_search { 2566 | Self::find_whole_word_bool(text, search_string) 2567 | } else { 2568 | Self::find_occurence_bool(text, search_string) 2569 | }; 2570 | 2571 | if find_result { 2572 | found.push(pos); 2573 | } 2574 | } 2575 | } 2576 | *update_scroll = true; 2577 | if found.len() > 1 { 2578 | *position = 1; 2579 | } 2580 | } 2581 | } 2582 | 2583 | fn ui(&mut self, ui: &mut Ui, settings: &Settings) { 2584 | use SynchronousSubtitlesWindow::*; 2585 | let mut update = None; 2586 | let mut update_search = false; 2587 | match self { 2588 | Load { 2589 | lang1, 2590 | lang2, 2591 | error1, 2592 | error2, 2593 | } => { 2594 | if ui.button("Use these subtitles").clicked() { 2595 | let sub1 = match srtparse::from_str(&lang1) { 2596 | Ok(sub1) => Some(sub1), 2597 | Err(err) => { 2598 | *error1 = Some(format!("{:#?}", err)); 2599 | None 2600 | } 2601 | }; 2602 | let sub2 = match srtparse::from_str(&lang2) { 2603 | Ok(sub2) => Some(sub2), 2604 | Err(err) => { 2605 | *error2 = Some(format!("{:#?}", err)); 2606 | None 2607 | } 2608 | }; 2609 | update = sub1.zip(sub2); 2610 | } 2611 | if error1.is_some() || error2.is_some() { 2612 | ui.separator(); 2613 | if let Some(error1) = error1 { 2614 | ui.horizontal_wrapped(|ui| { 2615 | ui.spacing_mut().item_spacing.x = 0.; 2616 | ui.add( 2617 | Label::new("Left Error: ") 2618 | .text_color(settings.color_error()) 2619 | .monospace(), 2620 | ); 2621 | ui.monospace(&**error1); 2622 | }); 2623 | } 2624 | if let Some(error2) = error2 { 2625 | ui.horizontal_wrapped(|ui| { 2626 | ui.spacing_mut().item_spacing.x = 0.; 2627 | ui.add( 2628 | Label::new("Right Error: ") 2629 | .text_color(settings.color_error()) 2630 | .monospace(), 2631 | ); 2632 | ui.monospace(&**error2); 2633 | }); 2634 | } 2635 | } 2636 | ui.separator(); 2637 | ui.columns(2, |cols| { 2638 | cols[0].text_edit_multiline(lang1); 2639 | cols[1].text_edit_multiline(lang2); 2640 | }); 2641 | } 2642 | View { 2643 | search_string, 2644 | whole_words_search, 2645 | found, 2646 | position, 2647 | phrases, 2648 | update_scroll: update_scroll_origin, 2649 | } => { 2650 | let mut update_scroll = *update_scroll_origin; 2651 | *update_scroll_origin = false; 2652 | if ui 2653 | .add( 2654 | TextEdit::singleline(search_string) 2655 | .hint_text("Type here to find word..."), 2656 | ) 2657 | .changed() 2658 | { 2659 | update_search = true; 2660 | } 2661 | ui.horizontal(|ui| { 2662 | if ui 2663 | .checkbox(whole_words_search, "Search by whole words") 2664 | .changed() 2665 | { 2666 | update_search = true; 2667 | } 2668 | ui.separator(); 2669 | if ui.add_enabled(*position > 1, Button::new("◀")).clicked() { 2670 | *position -= 1; 2671 | update_scroll = true; 2672 | } 2673 | if ui 2674 | .add_enabled(*position + 1 < found.len(), Button::new("▶")) 2675 | .clicked() 2676 | { 2677 | *position += 1; 2678 | update_scroll = true; 2679 | } 2680 | ui.label(format!("{}/{}", *position, found.len() - 1)); 2681 | }); 2682 | ui.separator(); 2683 | ScrollArea::vertical().max_height(200.0).show(ui, |ui| { 2684 | Grid::new("view_grid") 2685 | .spacing([4.0, 4.0]) 2686 | .max_col_width(150.) 2687 | .striped(true) 2688 | .show(ui, |ui| { 2689 | for (pos, (a, b)) in phrases.iter().enumerate() { 2690 | ui.label(format!("{}", pos + 1)); 2691 | if !found.is_empty() && found[*position] == pos { 2692 | let response = if let Some(text) = a { 2693 | if *position == 0 { 2694 | ui.label(text) 2695 | } else { 2696 | ui.add(Label::new(&text).strong()) 2697 | } 2698 | } else { 2699 | ui.label("-") 2700 | }; 2701 | if update_scroll { 2702 | response.scroll_to_me(Align::Center); 2703 | } 2704 | } else { 2705 | if let Some(text) = a { 2706 | ui.label(text); 2707 | } else { 2708 | ui.label("-"); 2709 | } 2710 | } 2711 | if let Some(text) = b { 2712 | ui.label(text); 2713 | } else { 2714 | ui.label("-"); 2715 | } 2716 | ui.end_row(); 2717 | } 2718 | }); 2719 | }); 2720 | } 2721 | } 2722 | if update_search { 2723 | self.update(); 2724 | } 2725 | if let Some((sub1, sub2)) = update { 2726 | let phrases = Self::calc_phrases(sub1, sub2); 2727 | *self = View { 2728 | search_string: String::new(), 2729 | whole_words_search: false, 2730 | found: vec![0], 2731 | position: 0, 2732 | phrases, 2733 | update_scroll: false, 2734 | }; 2735 | self.update(); 2736 | } 2737 | } 2738 | } 2739 | 2740 | struct GithubDayData { 2741 | attempts: u64, 2742 | time: f64, 2743 | new_unknown_words_count: u64, 2744 | } 2745 | 2746 | struct GithubActivityWindow { 2747 | max_day: Day, 2748 | min_day: Day, 2749 | 2750 | data_by_day: BTreeMap, 2751 | max_value: GithubDayData, 2752 | min_value: GithubDayData, 2753 | 2754 | show: u8, 2755 | 2756 | show_day: Day, 2757 | drag_delta: f32, 2758 | } 2759 | 2760 | impl WindowTrait for GithubActivityWindow { 2761 | fn create_window(&self) -> Window<'static> { 2762 | Window::new("Activity") 2763 | .vscroll(false) 2764 | .collapsible(false) 2765 | .resizable(false) 2766 | } 2767 | } 2768 | 2769 | fn date_from_day(day: Day) -> chrono::Date { 2770 | use chrono::TimeZone; 2771 | chrono::Utc 2772 | .timestamp(day.0 as i64 * 24 * 60 * 60 + 3600, 0) 2773 | .date() 2774 | } 2775 | 2776 | impl GithubActivityWindow { 2777 | fn new(stats: &Statistics, today: Day) -> Self { 2778 | let data_by_day: BTreeMap = stats 2779 | .by_day 2780 | .iter() 2781 | .map(|(d, x)| { 2782 | ( 2783 | *d, 2784 | GithubDayData { 2785 | attempts: x.attempts.right + x.attempts.wrong, 2786 | time: x.working_time, 2787 | new_unknown_words_count: x.new_unknown_words_count, 2788 | }, 2789 | ) 2790 | }) 2791 | .collect(); 2792 | let min_value = GithubDayData { 2793 | attempts: data_by_day.values().map(|x| x.attempts).min().unwrap(), 2794 | time: data_by_day 2795 | .values() 2796 | .map(|x| x.time) 2797 | .min_by(|x, y| x.partial_cmp(y).unwrap()) 2798 | .unwrap(), 2799 | new_unknown_words_count: data_by_day 2800 | .values() 2801 | .map(|x| x.new_unknown_words_count) 2802 | .min() 2803 | .unwrap(), 2804 | }; 2805 | let max_value = GithubDayData { 2806 | attempts: data_by_day.values().map(|x| x.attempts).max().unwrap(), 2807 | time: data_by_day 2808 | .values() 2809 | .map(|x| x.time) 2810 | .max_by(|x, y| x.partial_cmp(y).unwrap()) 2811 | .unwrap(), 2812 | new_unknown_words_count: data_by_day 2813 | .values() 2814 | .map(|x| x.new_unknown_words_count) 2815 | .max() 2816 | .unwrap(), 2817 | }; 2818 | Self { 2819 | min_day: *data_by_day.keys().next().unwrap(), 2820 | max_day: today, 2821 | 2822 | data_by_day, 2823 | max_value, 2824 | min_value, 2825 | 2826 | show: 0, 2827 | 2828 | show_day: today, 2829 | drag_delta: 0., 2830 | } 2831 | } 2832 | 2833 | fn get_normalized_value(&self, day: Day) -> Option { 2834 | fn normalize(min: f64, max: f64, v: f64) -> f64 { 2835 | (v - min) / (max - min) 2836 | } 2837 | 2838 | match self.show { 2839 | 0 => self.data_by_day.get(&day).map(|x| { 2840 | normalize( 2841 | self.min_value.attempts as f64, 2842 | self.max_value.attempts as f64, 2843 | x.attempts as f64, 2844 | ) 2845 | }), 2846 | 1 => self 2847 | .data_by_day 2848 | .get(&day) 2849 | .map(|x| normalize(self.min_value.time, self.max_value.time, x.time)), 2850 | _ => self.data_by_day.get(&day).map(|x| { 2851 | normalize( 2852 | self.min_value.new_unknown_words_count as f64, 2853 | self.max_value.new_unknown_words_count as f64, 2854 | x.new_unknown_words_count as f64, 2855 | ) 2856 | }), 2857 | } 2858 | } 2859 | 2860 | fn get_value_text(&self, day: Day) -> Option { 2861 | self.data_by_day.get(&day).map(|x| { 2862 | format!( 2863 | "Attempts: {}\nTime: {}\nNew words: {}\nTime for 1 attempt: {:.1}s", 2864 | x.attempts, 2865 | print_time(x.time), 2866 | x.new_unknown_words_count, 2867 | x.time / x.attempts as f64 2868 | ) 2869 | }) 2870 | } 2871 | 2872 | fn ui(&mut self, ui: &mut Ui, settings: &Settings) { 2873 | ui.horizontal(|ui| { 2874 | ui.label("Show data about: "); 2875 | ui.selectable_value(&mut self.show, 0, "Attempts"); 2876 | ui.selectable_value(&mut self.show, 1, "Working time"); 2877 | ui.selectable_value(&mut self.show, 2, "New words"); 2878 | }); 2879 | ui.separator(); 2880 | 2881 | let size = 8.; 2882 | let margin = 1.5; 2883 | let weeks = 53; 2884 | let days = 7; 2885 | 2886 | let month_size = ui.fonts()[TextStyle::Body].row_height(); 2887 | let weekday_size = 30.; 2888 | 2889 | let desired_size = egui::vec2( 2890 | 2. * margin + weeks as f32 * (size + margin) + weekday_size, 2891 | 2. * margin + days as f32 * (size + margin) + month_size * 2., 2892 | ); 2893 | let (rect, response) = ui.allocate_exact_size(desired_size, Sense::drag()); 2894 | 2895 | self.drag_delta += response.drag_delta().x; 2896 | let offset_weeks = (self.drag_delta / (size + margin)) as i64; 2897 | let show_day = Day((self.show_day.0 as i64 - offset_weeks * 7) as u64); 2898 | 2899 | use chrono::Datelike; 2900 | let today_date = date_from_day(show_day); 2901 | let today_week = today_date.weekday().number_from_monday() - 1; 2902 | let today_pos = 52 * 7 + today_week; 2903 | 2904 | let min = rect.min + egui::vec2(margin + weekday_size, margin + month_size); 2905 | let size2 = egui::vec2(size, size); 2906 | let margin2 = egui::vec2(margin, margin) / 2.; 2907 | let stroke_hovered = Stroke::new(1., settings.color_github_month()); 2908 | let stroke_month = Stroke::new(0.5, settings.color_github_month()); 2909 | let stroke_year = Stroke::new(1., settings.color_github_year()); 2910 | let left_1 = egui::vec2(-margin / 2., -margin / 2.); 2911 | let right_1 = egui::vec2(size + margin / 2., -margin / 2.); 2912 | let right_2 = egui::vec2(size + margin / 2., -margin / 2. - month_size); 2913 | let down_1 = egui::vec2(-margin / 2., size + margin / 2.); 2914 | let end_line = egui::vec2(size + margin / 2., size + margin / 2.); 2915 | let end_line2 = egui::vec2(size + margin / 2., size + margin / 2. + month_size); 2916 | let mut month_pos = BTreeMap::new(); 2917 | let mut year_pos = BTreeMap::new(); 2918 | for i in 0..weeks { 2919 | for j in 0..days { 2920 | let pos = i * 7 + j; 2921 | let day = Day(show_day.0 - today_pos as u64 + pos); 2922 | let date = date_from_day(day); 2923 | 2924 | if j + 1 == days { 2925 | month_pos 2926 | .entry((date.month(), date.year())) 2927 | .or_insert_with(Vec::new) 2928 | .push(i); 2929 | } 2930 | if j == 0 { 2931 | year_pos.entry(date.year()).or_insert_with(Vec::new).push(i); 2932 | } 2933 | 2934 | let pos = 2935 | min + egui::vec2(i as f32 * (size + margin), j as f32 * (size + margin)); 2936 | 2937 | if i + 1 != weeks { 2938 | let pos_right = (i + 1) * 7 + j; 2939 | let day_right = Day(show_day.0 - today_pos as u64 + pos_right); 2940 | let date_right = date_from_day(day_right); 2941 | 2942 | if date_right.year() != date.year() { 2943 | if j == 0 { 2944 | ui.painter() 2945 | .line_segment([pos + right_2, pos + end_line2], stroke_year); 2946 | } else if j + 1 == days { 2947 | ui.painter() 2948 | .line_segment([pos + right_1, pos + end_line2], stroke_year); 2949 | } else { 2950 | ui.painter() 2951 | .line_segment([pos + right_1, pos + end_line], stroke_year); 2952 | } 2953 | } else if date_right.month() != date.month() { 2954 | if j + 1 == days { 2955 | ui.painter() 2956 | .line_segment([pos + right_1, pos + end_line2], stroke_month); 2957 | } else { 2958 | ui.painter() 2959 | .line_segment([pos + right_1, pos + end_line], stroke_month); 2960 | } 2961 | } 2962 | } 2963 | 2964 | if j == 0 { 2965 | ui.painter() 2966 | .line_segment([pos + left_1, pos + right_1], stroke_month); 2967 | } else if j + 1 == days { 2968 | ui.painter() 2969 | .line_segment([pos + down_1, pos + end_line], stroke_month); 2970 | } 2971 | 2972 | if j + 1 != days { 2973 | let pos_down = i * 7 + (j + 1); 2974 | let day_down = Day(show_day.0 - today_pos as u64 + pos_down); 2975 | let date_down = date_from_day(day_down); 2976 | 2977 | if date_down.year() != date.year() { 2978 | ui.painter() 2979 | .line_segment([pos + down_1, pos + end_line], stroke_year); 2980 | } else if date_down.month() != date.month() { 2981 | ui.painter() 2982 | .line_segment([pos + down_1, pos + end_line], stroke_month); 2983 | } 2984 | } 2985 | 2986 | let color = if day.0 < self.min_day.0 || day.0 > self.max_day.0 { 2987 | settings.color_github_zero() 2988 | } else if let Some(value) = self.get_normalized_value(day) { 2989 | let zero_color = settings.color_github_zero(); 2990 | let min_color = settings.color_github_low(); 2991 | let max_color = settings.color_github_high(); 2992 | 2993 | let value = if settings.white_theme { 2994 | (value as f32).powf(0.7) 2995 | } else { 2996 | (value as f32).powf(0.71) 2997 | }; 2998 | 2999 | if value < 0.1 { 3000 | let value = value / 0.1; 3001 | Color32::from(lerp( 3002 | Rgba::from(zero_color)..=Rgba::from(min_color), 3003 | value, 3004 | )) 3005 | } else { 3006 | let value = (value - 0.1) / (1.0 - 0.1); 3007 | Color32::from(lerp( 3008 | Rgba::from(min_color)..=Rgba::from(max_color), 3009 | value, 3010 | )) 3011 | } 3012 | } else { 3013 | ui.visuals().faint_bg_color 3014 | }; 3015 | 3016 | let mut rect = egui::Rect::from_min_max(pos, pos + size2); 3017 | 3018 | ui.painter().rect_filled(rect, 0., color); 3019 | 3020 | if let Some(pos) = response.hover_pos() { 3021 | rect.min -= margin2; 3022 | rect.max += margin2; 3023 | if rect.contains(pos) && !response.dragged() { 3024 | let data = self.get_value_text(day); 3025 | let text = format!("{}-{}-{}", date.year(), date.month(), date.day()) 3026 | + if data.is_some() { "\n" } else { "" } 3027 | + &data.unwrap_or_else(String::new); 3028 | egui::show_tooltip_text(ui.ctx(), egui::Id::new("date tooltip"), text); 3029 | ui.painter() 3030 | .rect(rect, 0., Color32::TRANSPARENT, stroke_hovered); 3031 | } 3032 | } 3033 | } 3034 | } 3035 | for ((month, _), pos) in &month_pos { 3036 | if pos.len() < 3 { 3037 | continue; 3038 | } 3039 | let pos = pos.iter().sum::() as f32 / pos.len() as f32; 3040 | let pos = min + egui::vec2(pos * (size + margin), 7. * (size + margin)); 3041 | let month = match month { 3042 | 1 => "Jan", 3043 | 2 => "Feb", 3044 | 3 => "Mar", 3045 | 4 => "Apr", 3046 | 5 => "May", 3047 | 6 => "Jun", 3048 | 7 => "Jul", 3049 | 8 => "Aug", 3050 | 9 => "Sep", 3051 | 10 => "Oct", 3052 | 11 => "Nov", 3053 | 12 => "Dec", 3054 | _ => unreachable!(), 3055 | }; 3056 | ui.painter().text( 3057 | pos, 3058 | Align2::CENTER_TOP, 3059 | month, 3060 | TextStyle::Body, 3061 | ui.visuals().text_color(), 3062 | ); 3063 | } 3064 | for (year, pos) in &year_pos { 3065 | if pos.len() < 3 { 3066 | continue; 3067 | } 3068 | let pos = pos.iter().sum::() as f32 / pos.len() as f32; 3069 | let pos = min + egui::vec2(pos * (size + margin), -month_size - margin); 3070 | let year = year.to_string(); 3071 | ui.painter().text( 3072 | pos, 3073 | Align2::CENTER_TOP, 3074 | year, 3075 | TextStyle::Body, 3076 | ui.visuals().text_color(), 3077 | ); 3078 | } 3079 | ui.painter().text( 3080 | min + egui::vec2(-weekday_size, size / 2.), 3081 | Align2::LEFT_CENTER, 3082 | "Mon", 3083 | TextStyle::Body, 3084 | ui.visuals().text_color(), 3085 | ); 3086 | ui.painter().text( 3087 | min + egui::vec2(-weekday_size, size * 7. + size / 2.), 3088 | Align2::LEFT_CENTER, 3089 | "Sun", 3090 | TextStyle::Body, 3091 | ui.visuals().text_color(), 3092 | ); 3093 | } 3094 | } 3095 | 3096 | struct ToTypeToday { 3097 | all_words: Vec, 3098 | current_batch: Vec, 3099 | } 3100 | 3101 | // Это окно нельзя закрыть 3102 | struct LearnWordsWindow { 3103 | to_type_repeat: Vec<(String, u64)>, 3104 | to_type_new: Vec<(String, u64)>, 3105 | 3106 | to_type_today: Option, 3107 | current: LearnWords, 3108 | } 3109 | 3110 | enum LearnWords { 3111 | None, 3112 | Choose { 3113 | all_repeat: usize, 3114 | all_new: usize, 3115 | n_repeat: usize, 3116 | n_new: usize, 3117 | }, 3118 | Typing { 3119 | word: String, 3120 | word_by_hint: Option, 3121 | correct_answer: WordsToLearn, 3122 | words_to_type: Vec, 3123 | words_to_guess: Vec, 3124 | max_types: u8, 3125 | gain_focus: bool, 3126 | }, 3127 | Checked { 3128 | word: String, 3129 | known_words: Vec, 3130 | typed: Vec, 3131 | to_repeat: Vec, 3132 | result: Vec, 3133 | max_types: u8, 3134 | gain_focus: bool, 3135 | }, 3136 | } 3137 | 3138 | struct TypedWord { 3139 | correct: bool, 3140 | translation: String, 3141 | typed: String, 3142 | } 3143 | 3144 | fn select_with_translations( 3145 | word: &str, 3146 | words: &Words, 3147 | today: Day, 3148 | type_count: &[LearnType], 3149 | mut f: impl FnMut(&str), 3150 | ) { 3151 | f(word); 3152 | if let Some(variants) = words.0.get(word) { 3153 | for i in variants { 3154 | if i.can_learn_today(today, type_count) { 3155 | if let WordStatus::ToLearn { translation, .. } = i { 3156 | f(translation); 3157 | } 3158 | } 3159 | } 3160 | } 3161 | } 3162 | 3163 | impl LearnWordsWindow { 3164 | fn new(words: &Words, today: Day, type_count: &[LearnType], rng: &mut Rand) -> Self { 3165 | let mut result = Self { 3166 | to_type_repeat: Vec::new(), 3167 | to_type_new: Vec::new(), 3168 | 3169 | to_type_today: None, 3170 | current: LearnWords::None, 3171 | }; 3172 | result.update(words, today, type_count, rng); 3173 | result 3174 | } 3175 | 3176 | fn cancel_learning(&mut self) { 3177 | self.to_type_today = None; 3178 | self.current = LearnWords::Choose { 3179 | all_repeat: self.to_type_repeat.len(), 3180 | all_new: self.to_type_new.len(), 3181 | n_repeat: 30, 3182 | n_new: 15, 3183 | }; 3184 | } 3185 | 3186 | fn pick_current_type( 3187 | &mut self, 3188 | words: &Words, 3189 | today: Day, 3190 | type_count: &[LearnType], 3191 | rng: &mut Rand, 3192 | ) { 3193 | if let Some(to_type_today) = &mut self.to_type_today { 3194 | to_type_today 3195 | .all_words 3196 | .retain(|x| words.can_learn_today(x, today, type_count)); 3197 | } 3198 | 3199 | loop { 3200 | if self.to_type_repeat.is_empty() 3201 | && self.to_type_new.is_empty() 3202 | && self 3203 | .to_type_today 3204 | .as_ref() 3205 | .map(|x| x.current_batch.is_empty() && x.all_words.is_empty()) 3206 | .unwrap_or(true) 3207 | { 3208 | self.current = LearnWords::None; 3209 | return; 3210 | } 3211 | 3212 | if self 3213 | .to_type_today 3214 | .as_ref() 3215 | .map(|x| x.all_words.is_empty()) 3216 | .unwrap_or(false) 3217 | { 3218 | self.to_type_today = None; 3219 | } 3220 | 3221 | if let Some(to_type_today) = &mut self.to_type_today { 3222 | if to_type_today.current_batch.is_empty() { 3223 | let (hint_words, guess_words): (Vec<_>, Vec<_>) = to_type_today 3224 | .all_words 3225 | .iter() 3226 | .cloned() 3227 | .partition(|x| words.has_hint(x, type_count)); 3228 | 3229 | if hint_words.is_empty() { 3230 | to_type_today.current_batch = guess_words; 3231 | } else { 3232 | to_type_today.current_batch = hint_words; 3233 | } 3234 | 3235 | to_type_today.current_batch.shuffle(rng); 3236 | } 3237 | 3238 | let word = to_type_today.current_batch.remove(0); 3239 | if !words.is_learned(&word) { 3240 | let max_types = words.max_attempts_remains(&word, today, type_count); 3241 | let result = words.get_word_to_learn(&word, today, type_count); 3242 | let words_to_type: Vec = (0..result.words_to_type.len()) 3243 | .map(|_| String::new()) 3244 | .collect(); 3245 | let words_to_guess: Vec = (0..result.words_to_guess.len()) 3246 | .map(|_| String::new()) 3247 | .collect(); 3248 | if words_to_type.is_empty() && words_to_guess.is_empty() { 3249 | to_type_today.all_words.retain(|x| *x != word); 3250 | to_type_today.current_batch.retain(|x| *x != word); 3251 | } else { 3252 | self.current = LearnWords::Typing { 3253 | word, 3254 | word_by_hint: (!words_to_type.is_empty()).then(String::new), 3255 | correct_answer: result, 3256 | words_to_type, 3257 | max_types, 3258 | words_to_guess, 3259 | gain_focus: true, 3260 | }; 3261 | return; 3262 | } 3263 | } else { 3264 | to_type_today.all_words.retain(|x| *x != word); 3265 | to_type_today.current_batch.retain(|x| *x != word); 3266 | } 3267 | } else { 3268 | self.cancel_learning(); 3269 | return; 3270 | } 3271 | } 3272 | } 3273 | 3274 | fn update(&mut self, words: &Words, today: Day, type_count: &[LearnType], rng: &mut Rand) { 3275 | let (repeat, new) = words.get_words_to_learn_today(today, type_count); 3276 | 3277 | self.to_type_repeat.clear(); 3278 | for i in repeat { 3279 | let overdue = words.max_overdue_days(&i, today, type_count); 3280 | self.to_type_repeat.push((i, overdue)); 3281 | } 3282 | self.to_type_repeat.sort_by_key(|x| std::cmp::Reverse(x.1)); 3283 | 3284 | self.to_type_new.clear(); 3285 | for i in new { 3286 | let overdue = words.max_overdue_days(&i, today, type_count); 3287 | self.to_type_new.push((i, overdue)); 3288 | } 3289 | self.to_type_new.sort_by_key(|x| std::cmp::Reverse(x.1)); 3290 | 3291 | self.pick_current_type(words, today, type_count, rng); 3292 | } 3293 | 3294 | #[allow(clippy::too_many_arguments)] 3295 | fn ui( 3296 | &mut self, 3297 | ctx: &CtxRef, 3298 | words: &mut Words, 3299 | today: Day, 3300 | day_stats: &mut DayStatistics, 3301 | settings: &Settings, 3302 | save: &mut bool, 3303 | rng: &mut Rand, 3304 | ) { 3305 | let mut cancel = false; 3306 | egui::Window::new("Learn words") 3307 | .fixed_size((300., 0.)) 3308 | .collapsible(false) 3309 | .vscroll(false) 3310 | .show(ctx, |ui| match &mut self.current { 3311 | LearnWords::None => { 3312 | ui.label("🎉🎉🎉 Everything is learned for today! 🎉🎉🎉"); 3313 | } 3314 | LearnWords::Choose { 3315 | all_repeat, 3316 | all_new, 3317 | n_repeat, 3318 | n_new, 3319 | } => { 3320 | ui.label("Choose words to work with now."); 3321 | ui.horizontal(|ui| { 3322 | ui.label("Old words to repeat: "); 3323 | ui.add( 3324 | egui::DragValue::new(n_repeat) 3325 | .clamp_range(0..=*all_repeat) 3326 | .speed(1.0), 3327 | ); 3328 | ui.label(format!("/{}", all_repeat)) 3329 | }); 3330 | ui.horizontal(|ui| { 3331 | ui.label("New words to learn: "); 3332 | ui.add( 3333 | egui::DragValue::new(n_new) 3334 | .clamp_range(0..=*all_new) 3335 | .speed(1.0), 3336 | ); 3337 | ui.label(format!("/{}", all_new)) 3338 | }); 3339 | if ui.button("Choose").clicked() { 3340 | let to_type_repeat = &mut self.to_type_repeat; 3341 | let to_type_new = &mut self.to_type_new; 3342 | 3343 | self.to_type_today = Some({ 3344 | let mut result = BTreeSet::new(); 3345 | 3346 | while (n_repeat == all_repeat || result.len() < *n_repeat) 3347 | && !to_type_repeat.is_empty() 3348 | { 3349 | let first = to_type_repeat[0].clone(); 3350 | select_with_translations( 3351 | &first.0, 3352 | words, 3353 | today, 3354 | &settings.type_count, 3355 | |word| { 3356 | to_type_repeat.retain(|x| x.0 != word); 3357 | result.insert(word.to_string()); 3358 | }, 3359 | ); 3360 | } 3361 | 3362 | *n_repeat = result.len(); 3363 | 3364 | while (n_new == all_new || result.len() < *n_repeat + *n_new) 3365 | && !to_type_new.is_empty() 3366 | { 3367 | let first = to_type_new[0].clone(); 3368 | select_with_translations( 3369 | &first.0, 3370 | words, 3371 | today, 3372 | &settings.type_count, 3373 | |word| { 3374 | to_type_new.retain(|x| x.0 != word); 3375 | result.insert(word.to_string()); 3376 | }, 3377 | ); 3378 | } 3379 | 3380 | ToTypeToday { 3381 | all_words: result.into_iter().collect(), 3382 | current_batch: Vec::new(), 3383 | } 3384 | }); 3385 | 3386 | self.pick_current_type(words, today, &settings.type_count, rng); 3387 | } 3388 | } 3389 | LearnWords::Typing { 3390 | word, 3391 | word_by_hint, 3392 | correct_answer, 3393 | words_to_type, 3394 | words_to_guess, 3395 | gain_focus, 3396 | max_types, 3397 | } => { 3398 | let len = self.to_type_today.as_ref().unwrap().all_words.len(); 3399 | ui.with_layout(Layout::right_to_left(), |ui| { 3400 | if ui.button("❌").clicked() { 3401 | cancel = true; 3402 | } 3403 | ui.with_layout(Layout::left_to_right(), |ui| { 3404 | ui.label(format!("Words remains: {}.", len)); 3405 | }); 3406 | }); 3407 | ui.label(format!("This word attempts remains: {}.", max_types)); 3408 | ui.separator(); 3409 | 3410 | let mut data = InputFieldData::new(settings, &mut *gain_focus); 3411 | 3412 | if let Some(word_by_hint) = word_by_hint { 3413 | ui.label("Word:"); 3414 | InputField::Hint.ui(ui, &mut data, word_by_hint, word, settings); 3415 | ui.separator(); 3416 | } else { 3417 | ui.add(Label::new(&word).heading().strong()); 3418 | } 3419 | 3420 | for i in &mut correct_answer.known_words { 3421 | ui.add_enabled(false, egui::TextEdit::singleline(i)); 3422 | } 3423 | for (hint, i) in correct_answer 3424 | .words_to_type 3425 | .iter() 3426 | .zip(words_to_type.iter_mut()) 3427 | { 3428 | InputField::Hint.ui(ui, &mut data, i, hint, settings); 3429 | } 3430 | for (i, correct) in words_to_guess 3431 | .iter_mut() 3432 | .zip(correct_answer.words_to_guess.iter()) 3433 | { 3434 | InputField::Input.ui(ui, &mut data, i, correct, settings); 3435 | } 3436 | 3437 | if input_field_button(ui, "Check", &mut data) { 3438 | // Register just typed words 3439 | for answer in &correct_answer.words_to_type { 3440 | words.register_attempt( 3441 | word, 3442 | answer, 3443 | true, 3444 | today, 3445 | day_stats, 3446 | &settings.type_count, 3447 | ); 3448 | } 3449 | 3450 | let mut result = Vec::new(); 3451 | let mut answers = correct_answer.words_to_guess.clone(); 3452 | let mut corrects = Vec::new(); 3453 | for typed in &*words_to_guess { 3454 | if let Some(position) = answers.iter().position(|x| x == typed) { 3455 | corrects.push(answers.remove(position)); 3456 | } 3457 | } 3458 | 3459 | for typed in &*words_to_guess { 3460 | let (answer, correct) = if let Some(position) = 3461 | corrects.iter().position(|x| x == typed) 3462 | { 3463 | (corrects.remove(position), true) 3464 | } else { 3465 | (answers.remove(0), false) 3466 | }; 3467 | 3468 | result.push(TypedWord { 3469 | correct, 3470 | translation: answer, 3471 | typed: typed.clone(), 3472 | }); 3473 | } 3474 | 3475 | if result.is_empty() { 3476 | for typed_word in result.iter_mut() { 3477 | words.register_attempt( 3478 | word, 3479 | &typed_word.translation, 3480 | typed_word.correct, 3481 | today, 3482 | day_stats, 3483 | &settings.type_count, 3484 | ); 3485 | } 3486 | self.pick_current_type(words, today, &settings.type_count, rng); 3487 | *save = true; 3488 | } else { 3489 | self.current = LearnWords::Checked { 3490 | word: word.clone(), 3491 | known_words: correct_answer.known_words.clone(), 3492 | typed: correct_answer.words_to_type.clone(), 3493 | to_repeat: (0..result.len()).map(|_| String::new()).collect(), 3494 | result, 3495 | max_types: *max_types, 3496 | gain_focus: true, 3497 | }; 3498 | } 3499 | } 3500 | } 3501 | LearnWords::Checked { 3502 | word, 3503 | known_words, 3504 | typed, 3505 | result, 3506 | to_repeat, 3507 | max_types, 3508 | gain_focus, 3509 | } => { 3510 | let len = self.to_type_today.as_ref().unwrap().all_words.len(); 3511 | ui.with_layout(Layout::right_to_left(), |ui| { 3512 | if ui.button("❌").clicked() { 3513 | cancel = true; 3514 | } 3515 | ui.with_layout(Layout::left_to_right(), |ui| { 3516 | ui.label(format!("Words remains: {}.", len)); 3517 | }); 3518 | }); 3519 | ui.label(format!("This word attempts remains: {}.", max_types)); 3520 | ui.separator(); 3521 | ui.add(Label::new(&word).heading().strong()); 3522 | 3523 | let mut data = InputFieldData::new(settings, &mut *gain_focus); 3524 | 3525 | for i in known_words { 3526 | ui.add_enabled(false, egui::TextEdit::singleline(i)); 3527 | } 3528 | 3529 | for i in typed { 3530 | with_green_color( 3531 | ui, 3532 | |ui| { 3533 | ui.add_enabled(false, egui::TextEdit::singleline(i)); 3534 | }, 3535 | settings, 3536 | ); 3537 | } 3538 | 3539 | for word in result.iter_mut() { 3540 | InputField::Checked(&mut word.correct).ui( 3541 | ui, 3542 | &mut data, 3543 | &mut word.typed, 3544 | &word.translation, 3545 | settings, 3546 | ); 3547 | } 3548 | 3549 | if result.iter().any(|x| !x.correct) { 3550 | ui.separator(); 3551 | ui.label("Correction of mistakes:"); 3552 | } 3553 | 3554 | for (word, to_repeat) in result.iter_mut().zip(to_repeat.iter_mut()) { 3555 | if !word.correct { 3556 | InputField::Hint.ui( 3557 | ui, 3558 | &mut data, 3559 | to_repeat, 3560 | &word.translation, 3561 | settings, 3562 | ); 3563 | } 3564 | } 3565 | 3566 | if input_field_button(ui, "Next", &mut data) { 3567 | for typed_word in result.iter_mut() { 3568 | words.register_attempt( 3569 | word, 3570 | &typed_word.translation, 3571 | typed_word.correct, 3572 | today, 3573 | day_stats, 3574 | &settings.type_count, 3575 | ); 3576 | } 3577 | self.pick_current_type(words, today, &settings.type_count, rng); 3578 | *save = true; 3579 | } 3580 | } 3581 | }); 3582 | if cancel { 3583 | self.update(words, today, &settings.type_count, rng); 3584 | self.cancel_learning(); 3585 | } 3586 | } 3587 | } 3588 | 3589 | enum InputField<'a> { 3590 | Hint, 3591 | Input, 3592 | Checked(&'a mut bool), 3593 | } 3594 | 3595 | struct FocusThing { 3596 | last_response: Option, 3597 | last_enabled_response: Option, 3598 | give_next_focus: u8, 3599 | } 3600 | 3601 | struct InputFieldData<'a> { 3602 | f: FocusThing, 3603 | focus_gained: bool, 3604 | gain_focus: &'a mut bool, 3605 | settings: &'a Settings, 3606 | is_empty: bool, 3607 | 3608 | next_enabled: bool, 3609 | } 3610 | 3611 | impl Drop for FocusThing { 3612 | fn drop(&mut self) { 3613 | if self.give_next_focus == 1 { 3614 | if let Some(last) = &mut self.last_enabled_response { 3615 | last.request_focus(); 3616 | } 3617 | } 3618 | } 3619 | } 3620 | 3621 | impl<'a> InputFieldData<'a> { 3622 | fn new(settings: &'a Settings, gain_focus: &'a mut bool) -> InputFieldData<'a> { 3623 | Self { 3624 | f: FocusThing { 3625 | last_response: None, 3626 | last_enabled_response: None, 3627 | give_next_focus: 0, 3628 | }, 3629 | focus_gained: false, 3630 | gain_focus, 3631 | settings, 3632 | is_empty: false, 3633 | 3634 | next_enabled: true, 3635 | } 3636 | } 3637 | 3638 | fn process_text(&self, input: &mut String, should_be: &str) { 3639 | if self.settings.use_keyboard_layout { 3640 | self.settings.keyboard_layout.change(should_be, input); 3641 | } 3642 | } 3643 | 3644 | fn process_focus(&mut self, response: Response, input: &InputState, allow_gain: bool) { 3645 | if self.f.give_next_focus == 1 && self.next_enabled { 3646 | response.request_focus(); 3647 | self.f.give_next_focus = 2; 3648 | } 3649 | if response.has_focus() 3650 | && input.events.iter().any(|x| { 3651 | if let Event::Key { key, pressed, .. } = x { 3652 | *key == Key::Backspace && *pressed 3653 | } else { 3654 | false 3655 | } 3656 | }) 3657 | && self.is_empty 3658 | { 3659 | if let Some(last_response) = &self.f.last_response { 3660 | last_response.request_focus(); 3661 | } 3662 | } 3663 | if response.lost_focus() 3664 | && input.events.iter().any(|x| { 3665 | if let Event::Key { key, pressed, .. } = x { 3666 | *key == Key::Enter && *pressed 3667 | } else { 3668 | false 3669 | } 3670 | }) 3671 | && self.f.give_next_focus == 0 3672 | { 3673 | self.f.give_next_focus = 1; 3674 | } 3675 | if !self.focus_gained && *self.gain_focus && self.next_enabled && allow_gain { 3676 | response.request_focus(); 3677 | self.focus_gained = true; 3678 | *self.gain_focus = false; 3679 | } 3680 | if response.enabled() { 3681 | self.f.last_enabled_response = Some(response.clone()); 3682 | } 3683 | self.f.last_response = Some(response); 3684 | } 3685 | } 3686 | 3687 | fn input_field_button(ui: &mut Ui, text: &str, data: &mut InputFieldData) -> bool { 3688 | data.is_empty = true; 3689 | let response = ui.add_enabled(data.next_enabled, Button::new(text)); 3690 | let result = response.clicked(); 3691 | data.process_focus(response, ui.input(), true); 3692 | result 3693 | } 3694 | 3695 | impl InputField<'_> { 3696 | fn ui( 3697 | &mut self, 3698 | ui: &mut Ui, 3699 | data: &mut InputFieldData, 3700 | input: &mut String, 3701 | should_be: &str, 3702 | settings: &Settings, 3703 | ) { 3704 | use InputField::*; 3705 | match self { 3706 | Hint => { 3707 | data.is_empty = input.is_empty(); 3708 | let response = if input == should_be { 3709 | with_green_color( 3710 | ui, 3711 | |ui| { 3712 | ui.add_enabled( 3713 | data.next_enabled, 3714 | egui::TextEdit::singleline(input) 3715 | .hint_text(format!(" {}", should_be)), 3716 | ) 3717 | }, 3718 | settings, 3719 | ) 3720 | } else { 3721 | ui.add_enabled( 3722 | data.next_enabled, 3723 | egui::TextEdit::singleline(input).hint_text(format!(" {}", should_be)), 3724 | ) 3725 | }; 3726 | data.process_text(input, should_be); 3727 | data.process_focus(response, ui.input(), true); 3728 | data.next_enabled &= input == should_be; 3729 | } 3730 | Input => { 3731 | data.is_empty = input.is_empty(); 3732 | let response = 3733 | ui.add_enabled(data.next_enabled, egui::TextEdit::singleline(input)); 3734 | data.process_text(input, should_be); 3735 | data.process_focus(response, ui.input(), true); 3736 | } 3737 | Checked(checked) => { 3738 | ui.with_layout(Layout::right_to_left(), |ui| { 3739 | let response = ui.button("Invert"); 3740 | if response.clicked() { 3741 | **checked = !**checked; 3742 | } 3743 | data.process_focus(response, ui.input(), false); 3744 | if **checked { 3745 | ui.label(format!("✅ {}", should_be)); 3746 | with_green_color( 3747 | ui, 3748 | |ui| { 3749 | ui.add_enabled(false, egui::TextEdit::singleline(input)); 3750 | }, 3751 | settings, 3752 | ); 3753 | } else { 3754 | ui.label(format!("❌ {}", should_be)); 3755 | with_red_color( 3756 | ui, 3757 | |ui| { 3758 | ui.add_enabled(false, egui::TextEdit::singleline(input)); 3759 | }, 3760 | settings, 3761 | ); 3762 | } 3763 | }); 3764 | } 3765 | } 3766 | } 3767 | } 3768 | 3769 | fn word_to_add( 3770 | ui: &mut Ui, 3771 | word: &mut String, 3772 | translations: &mut String, 3773 | known_translations: &mut String, 3774 | ) -> Option<(String, WordsToAdd)> { 3775 | let mut action = None; 3776 | ui.horizontal(|ui| { 3777 | ui.label("Word:"); 3778 | ui.text_edit_singleline(word); 3779 | }); 3780 | ui.separator(); 3781 | ui.horizontal(|ui| { 3782 | if ui.button("Know this word").clicked() { 3783 | action = Some((word.clone(), WordsToAdd::KnowPreviously)); 3784 | } 3785 | if ui.button("Trash word").clicked() { 3786 | action = Some((word.clone(), WordsToAdd::TrashWord)); 3787 | } 3788 | }); 3789 | ui.separator(); 3790 | ui.label("Translations:"); 3791 | ui.add(TextEdit::multiline(translations).desired_rows(2)); 3792 | ui.separator(); 3793 | ui.label("Known translations:"); 3794 | ui.add(TextEdit::multiline(known_translations).desired_rows(2)); 3795 | if ui.button("Add these translations").clicked() { 3796 | action = Some(( 3797 | word.clone(), 3798 | WordsToAdd::ToLearn { 3799 | learned: known_translations 3800 | .split('\n') 3801 | .map(|x| x.to_string()) 3802 | .filter(|x| !x.is_empty()) 3803 | .collect(), 3804 | translations: translations 3805 | .split('\n') 3806 | .map(|x| x.to_string()) 3807 | .filter(|x| !x.is_empty()) 3808 | .collect(), 3809 | }, 3810 | )); 3811 | } 3812 | action 3813 | } 3814 | 3815 | fn with_color( 3816 | ui: &mut Ui, 3817 | color1: Color32, 3818 | color2: Color32, 3819 | color3: Color32, 3820 | f: impl FnOnce(&mut Ui) -> Res, 3821 | ) -> Res { 3822 | let previous = ui.visuals().clone(); 3823 | ui.visuals_mut().selection.stroke.color = color1; 3824 | ui.visuals_mut().widgets.inactive.bg_stroke.color = color2; 3825 | ui.visuals_mut().widgets.inactive.bg_stroke.width = 1.0; 3826 | ui.visuals_mut().widgets.hovered.bg_stroke.color = color3; 3827 | let result = f(ui); 3828 | *ui.visuals_mut() = previous; 3829 | result 3830 | } 3831 | 3832 | fn with_green_color( 3833 | ui: &mut Ui, 3834 | f: impl FnOnce(&mut Ui) -> Res, 3835 | settings: &Settings, 3836 | ) -> Res { 3837 | with_color( 3838 | ui, 3839 | settings.color_green_field_1(), 3840 | settings.color_green_field_2(), 3841 | settings.color_green_field_3(), 3842 | f, 3843 | ) 3844 | } 3845 | 3846 | fn with_red_color( 3847 | ui: &mut Ui, 3848 | f: impl FnOnce(&mut Ui) -> Res, 3849 | settings: &Settings, 3850 | ) -> Res { 3851 | with_color( 3852 | ui, 3853 | settings.color_red_field_1(), 3854 | settings.color_red_field_2(), 3855 | settings.color_red_field_3(), 3856 | f, 3857 | ) 3858 | } 3859 | 3860 | fn word_status_show_ui(word: &WordStatus, ui: &mut Ui) { 3861 | use WordStatus::*; 3862 | match word { 3863 | KnowPreviously => ui.label("Known"), 3864 | TrashWord => ui.label("Trash"), 3865 | ToLearn { 3866 | translation, 3867 | last_learn, 3868 | current_level, 3869 | current_count, 3870 | stats, 3871 | } => { 3872 | ui.label(format!("To learn: '{}'", translation)); 3873 | ui.label(format!("Attempts: +{}, -{}", stats.right, stats.wrong)); 3874 | ui.label(format!("Last learned: {} day", last_learn.0)); 3875 | ui.label(format!("Current level: {}", current_level)); 3876 | ui.label(format!("Current correct writes: {}", current_count)) 3877 | } 3878 | Learned { translation, stats } => { 3879 | ui.label(format!("Learned: '{}'", translation)); 3880 | ui.label(format!("Attempts: +{}, -{}", stats.right, stats.wrong)) 3881 | } 3882 | }; 3883 | } 3884 | 3885 | pub trait ComboBoxChoosable { 3886 | fn variants() -> &'static [&'static str]; 3887 | fn get_number(&self) -> usize; 3888 | fn set_number(&mut self, number: usize); 3889 | } 3890 | 3891 | impl ComboBoxChoosable for WordStatus { 3892 | fn variants() -> &'static [&'static str] { 3893 | &["Known", "Trash", "To learn", "Learned."] 3894 | } 3895 | fn get_number(&self) -> usize { 3896 | use WordStatus::*; 3897 | match self { 3898 | KnowPreviously => 0, 3899 | TrashWord => 1, 3900 | ToLearn { .. } => 2, 3901 | Learned { .. } => 3, 3902 | } 3903 | } 3904 | fn set_number(&mut self, number: usize) { 3905 | use WordStatus::*; 3906 | *self = match number { 3907 | 0 => KnowPreviously, 3908 | 1 => TrashWord, 3909 | 2 => { 3910 | if let Learned { translation, stats } = self { 3911 | ToLearn { 3912 | translation: translation.to_string(), 3913 | stats: *stats, 3914 | last_learn: Day(0), 3915 | current_level: 0, 3916 | current_count: 0, 3917 | } 3918 | } else { 3919 | ToLearn { 3920 | translation: String::new(), 3921 | stats: TypingStats { right: 0, wrong: 0 }, 3922 | last_learn: Day(0), 3923 | current_level: 0, 3924 | current_count: 0, 3925 | } 3926 | } 3927 | } 3928 | 3 => { 3929 | if let ToLearn { 3930 | translation, stats, .. 3931 | } = self 3932 | { 3933 | Learned { 3934 | translation: translation.to_string(), 3935 | stats: *stats, 3936 | } 3937 | } else { 3938 | Learned { 3939 | translation: String::new(), 3940 | stats: TypingStats { right: 0, wrong: 0 }, 3941 | } 3942 | } 3943 | } 3944 | _ => unreachable!(), 3945 | }; 3946 | } 3947 | } 3948 | 3949 | fn word_status_edit_ui( 3950 | word: &mut WordStatus, 3951 | ui: &mut Ui, 3952 | rename: &mut Option<(String, String)>, 3953 | is_delete: &mut bool, 3954 | settings: &Settings, 3955 | ) -> bool { 3956 | use WordStatus::*; 3957 | 3958 | let mut changed = false; 3959 | 3960 | let mut current_type = word.get_number(); 3961 | let previous_type = current_type; 3962 | 3963 | ui.with_layout(Layout::right_to_left(), |ui| { 3964 | if ui 3965 | .add(Button::new("Delete").text_color(settings.color_delete())) 3966 | .clicked() 3967 | { 3968 | *is_delete = true; 3969 | } 3970 | ui.with_layout(Layout::left_to_right(), |ui| { 3971 | for (pos, name) in WordStatus::variants().iter().enumerate().take(2) { 3972 | ui.selectable_value(&mut current_type, pos, *name); 3973 | } 3974 | }); 3975 | }); 3976 | 3977 | ui.horizontal(|ui| { 3978 | for (pos, name) in WordStatus::variants().iter().enumerate().skip(2) { 3979 | ui.selectable_value(&mut current_type, pos, *name); 3980 | } 3981 | }); 3982 | 3983 | if current_type != previous_type { 3984 | word.set_number(current_type); 3985 | changed = true; 3986 | } 3987 | 3988 | if let ToLearn { 3989 | translation, stats, .. 3990 | } 3991 | | Learned { translation, stats } = word 3992 | { 3993 | let previous = translation.clone(); 3994 | 3995 | ui.text_edit_singleline(translation); 3996 | 3997 | if previous != *translation { 3998 | *rename = Some((previous, translation.clone())); 3999 | } 4000 | 4001 | ui.horizontal(|ui| { 4002 | ui.label("Right attempts: "); 4003 | let response = ui.add( 4004 | egui::DragValue::new(&mut stats.right) 4005 | .clamp_range(0..=100) 4006 | .speed(1.0), 4007 | ); 4008 | if response.changed() { 4009 | changed = true; 4010 | } 4011 | }); 4012 | ui.horizontal(|ui| { 4013 | ui.label("Wrong attempts: "); 4014 | let response = ui.add( 4015 | egui::DragValue::new(&mut stats.wrong) 4016 | .clamp_range(0..=100) 4017 | .speed(1.0), 4018 | ); 4019 | if response.changed() { 4020 | changed = true; 4021 | } 4022 | }); 4023 | } 4024 | if let ToLearn { 4025 | last_learn, 4026 | current_level, 4027 | current_count, 4028 | .. 4029 | } = word 4030 | { 4031 | ui.horizontal(|ui| { 4032 | ui.label("Last learn: "); 4033 | let response = ui.add( 4034 | egui::DragValue::new(&mut last_learn.0) 4035 | .clamp_range(0..=100_000) 4036 | .speed(1.0), 4037 | ); 4038 | if response.changed() { 4039 | changed = true; 4040 | } 4041 | }); 4042 | ui.horizontal(|ui| { 4043 | ui.label("Current level: "); 4044 | let response = ui.add( 4045 | egui::DragValue::new(current_level) 4046 | .clamp_range(0..=100) 4047 | .speed(1.0), 4048 | ); 4049 | if response.changed() { 4050 | changed = true; 4051 | } 4052 | }); 4053 | ui.horizontal(|ui| { 4054 | ui.label("Current correct writes: "); 4055 | let response = ui.add( 4056 | egui::DragValue::new(current_count) 4057 | .clamp_range(0..=100) 4058 | .speed(1.0), 4059 | ); 4060 | if response.changed() { 4061 | changed = true; 4062 | } 4063 | }); 4064 | } 4065 | changed 4066 | } 4067 | } 4068 | 4069 | struct PauseDetector { 4070 | last_mouse_position: (f32, f32), 4071 | pausing: bool, 4072 | time: f64, 4073 | 4074 | last_time: f64, 4075 | time_without_pauses: f64, 4076 | } 4077 | 4078 | impl PauseDetector { 4079 | fn new(time_today: f64) -> Self { 4080 | Self { 4081 | last_mouse_position: (0., 0.), 4082 | pausing: false, 4083 | time: now(), 4084 | last_time: now(), 4085 | time_without_pauses: time_today, 4086 | } 4087 | } 4088 | 4089 | fn is_paused(&mut self, settings: &Settings, input: &egui::InputState) -> bool { 4090 | let current_mouse_position = { 4091 | let p = input.pointer.hover_pos().unwrap_or_default(); 4092 | (p.x, p.y) 4093 | }; 4094 | let mouse_offset = (self.last_mouse_position.0 - current_mouse_position.0).abs() 4095 | + (self.last_mouse_position.1 - current_mouse_position.1).abs(); 4096 | let mouse_not_moving = mouse_offset < 0.01; 4097 | let mouse_not_clicking = !input.pointer.any_down(); 4098 | let keyboard_not_typing = input.keys_down.is_empty(); 4099 | 4100 | self.last_mouse_position = current_mouse_position; 4101 | let now = now(); 4102 | if !(self.pausing && now - self.time > settings.time_to_pause) { 4103 | self.time_without_pauses += now - self.last_time; 4104 | } 4105 | self.last_time = now; 4106 | 4107 | if mouse_not_moving && keyboard_not_typing && mouse_not_clicking { 4108 | if self.pausing { 4109 | now - self.time > settings.time_to_pause 4110 | } else { 4111 | self.pausing = true; 4112 | self.time = now; 4113 | false 4114 | } 4115 | } else { 4116 | self.pausing = false; 4117 | false 4118 | } 4119 | } 4120 | 4121 | fn get_working_time(&mut self) -> &mut f64 { 4122 | &mut self.time_without_pauses 4123 | } 4124 | } 4125 | 4126 | use eframe::{egui, epi}; 4127 | 4128 | pub struct TemplateApp { 4129 | rng: Rand, 4130 | today: Day, 4131 | pause_detector: PauseDetector, 4132 | program: gui::Program, 4133 | init: bool, 4134 | } 4135 | 4136 | impl Default for TemplateApp { 4137 | fn default() -> Self { 4138 | #[cfg(not(target_arch = "wasm32"))] 4139 | color_backtrace::install(); 4140 | 4141 | #[cfg(target_arch = "wasm32")] 4142 | std::panic::set_hook(Box::new(console_error_panic_hook::hook)); 4143 | 4144 | fn current_day(hour_offset: f64) -> Day { 4145 | Day(((now() / 60. / 60. + hour_offset) / 24.) as _) 4146 | } 4147 | 4148 | let mut rng = Rand::seed_from_u64(now() as u64); 4149 | 4150 | let (words, settings, stats) = gui::Program::load(); 4151 | let today = current_day(timezone_offset_hours()); 4152 | 4153 | let mut pause_detector = PauseDetector::new( 4154 | stats 4155 | .by_day 4156 | .get(&today) 4157 | .map(|x| x.working_time) 4158 | .unwrap_or(0.), 4159 | ); 4160 | 4161 | let program = gui::Program::new( 4162 | words, 4163 | settings, 4164 | stats, 4165 | today, 4166 | *pause_detector.get_working_time(), 4167 | &mut rng, 4168 | ); 4169 | 4170 | Self { 4171 | rng, 4172 | today, 4173 | pause_detector, 4174 | program, 4175 | init: false, 4176 | } 4177 | } 4178 | } 4179 | 4180 | impl epi::App for TemplateApp { 4181 | fn name(&self) -> &str { 4182 | "Learn Words" 4183 | } 4184 | 4185 | fn update(&mut self, ctx: &egui::CtxRef, _: &mut epi::Frame<'_>) { 4186 | if !self.init { 4187 | self.init = true; 4188 | 4189 | if self.program.get_settings().white_theme { 4190 | ctx.set_visuals(egui::Visuals::light()); 4191 | } else { 4192 | ctx.set_visuals(egui::Visuals::dark()); 4193 | } 4194 | 4195 | ctx.set_pixels_per_point(self.program.get_settings().dpi); 4196 | } 4197 | 4198 | let mut fill = ctx.style().visuals.extreme_bg_color; 4199 | if !cfg!(target_arch = "wasm32") { 4200 | // Native: WrapApp uses a transparent window, so let's show that off: 4201 | // NOTE: the OS compositor assumes "normal" blending, so we need to hack it: 4202 | let [r, g, b, _] = fill.to_array(); 4203 | fill = egui::Color32::from_rgba_premultiplied(r, g, b, 180); 4204 | } 4205 | let frame = egui::Frame::none().fill(fill); 4206 | egui::CentralPanel::default().frame(frame).show(ctx, |_| {}); 4207 | 4208 | let paused = self 4209 | .pause_detector 4210 | .is_paused(self.program.get_settings(), ctx.input()); 4211 | self.program.ui( 4212 | ctx, 4213 | self.today, 4214 | self.pause_detector.get_working_time(), 4215 | &mut self.rng, 4216 | paused, 4217 | ); 4218 | } 4219 | } 4220 | 4221 | // ---------------------------------------------------------------------------- 4222 | 4223 | fn timezone_offset_hours() -> f64 { 4224 | #[cfg(not(target_arch = "wasm32"))] 4225 | { 4226 | use chrono::offset::Offset; 4227 | use chrono::offset::TimeZone; 4228 | use chrono::Local; 4229 | Local.timestamp(0, 0).offset().fix().local_minus_utc() as f64 / 3600. 4230 | } 4231 | 4232 | #[cfg(target_arch = "wasm32")] 4233 | { 4234 | -js_sys::Date::new_0().get_timezone_offset() / 60. 4235 | } 4236 | } 4237 | 4238 | pub fn now() -> f64 { 4239 | #[cfg(not(target_arch = "wasm32"))] 4240 | { 4241 | use std::time::SystemTime; 4242 | 4243 | let time = SystemTime::now() 4244 | .duration_since(SystemTime::UNIX_EPOCH) 4245 | .unwrap_or_else(|e| panic!("{}", e)); 4246 | time.as_secs_f64() 4247 | } 4248 | 4249 | #[cfg(target_arch = "wasm32")] 4250 | { 4251 | js_sys::Date::now() / 1000.0 4252 | } 4253 | } 4254 | 4255 | #[cfg(target_arch = "wasm32")] 4256 | pub fn download_as_file(text: &str) { 4257 | use wasm_bindgen::JsCast; 4258 | 4259 | let window = web_sys::window().unwrap(); 4260 | let document = window.document().unwrap(); 4261 | 4262 | let elem = document.create_element("a").unwrap(); 4263 | let data = format!( 4264 | "data:text/plain;charset=utf-8,{}", 4265 | String::from(js_sys::encode_uri_component(text)) 4266 | ); 4267 | elem.set_attribute("href", &data).unwrap(); 4268 | elem.set_attribute("download", "local.data").unwrap(); 4269 | 4270 | let elem = elem.unchecked_into::(); 4271 | 4272 | elem.style().set_property("display", "none").unwrap(); 4273 | 4274 | let body = document.body().expect("2"); 4275 | 4276 | body.append_child(&elem).expect("3"); 4277 | document.set_body(Some(&body)); 4278 | 4279 | elem.click(); 4280 | 4281 | body.remove_child(&elem).expect("6"); 4282 | document.set_body(Some(&body)); 4283 | } 4284 | 4285 | // ---------------------------------------------------------------------------- 4286 | 4287 | #[cfg(target_arch = "wasm32")] 4288 | use eframe::wasm_bindgen::{self, prelude::*}; 4289 | 4290 | #[cfg(target_arch = "wasm32")] 4291 | #[wasm_bindgen] 4292 | pub fn start(canvas_id: &str) -> Result<(), eframe::wasm_bindgen::JsValue> { 4293 | let app = TemplateApp::default(); 4294 | eframe::start_web(canvas_id, Box::new(app)) 4295 | } 4296 | 4297 | #[cfg(target_arch = "wasm32")] 4298 | fn main() {} 4299 | 4300 | #[cfg(not(target_arch = "wasm32"))] 4301 | fn main() { 4302 | let app = TemplateApp::default(); 4303 | let native_options = eframe::NativeOptions::default(); 4304 | eframe::run_native(Box::new(app), native_options); 4305 | } 4306 | --------------------------------------------------------------------------------