diff --git a/sql/custom_fields.sql b/sql/custom_fields.sql index ffc27b4..3db3e87 100644 --- a/sql/custom_fields.sql +++ b/sql/custom_fields.sql @@ -2,5 +2,5 @@ create table custom_fields ( id integer primary key, name text not null, - custom_fields text not null + identifier text not null ); \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 9254807..84a3ada 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,6 +17,7 @@ use log::info; use parking_lot::{Mutex, RwLock}; use rusqlite::Connection; use std::sync::Arc; +use crate::data_provider::custom_fields::CustomFieldRepository; pub const USER_FONT: Font = Font::with_name("Noto Sans JP"); @@ -65,6 +66,7 @@ pub struct AppState { pub decks: DeckRepository, pub word_groups: WordGroupRepository, pub card_states: CardStateRepository, + pub custom_fields: CustomFieldRepository, pub connection: Arc>, pub sync_data: Arc>, pub history: HistoryRepository, @@ -85,6 +87,7 @@ impl AppState { word_groups: WordGroupRepository::new(connection.clone()), card_states: CardStateRepository::new(connection.clone()), history: HistoryRepository::new(connection.clone()), + custom_fields: CustomFieldRepository::new(connection.clone()), sync_data: Arc::new(RwLock::new(AppSettings { key: None, auto_web_fetch: false, @@ -104,6 +107,7 @@ pub fn fill_state(state: &mut AppState) { state.decks.write().start().unwrap(); state.history.write().start().unwrap(); state.card_states.write().start().unwrap(); + state.custom_fields.write().start().unwrap(); state.sync_data = Arc::new(RwLock::new(setting)) } diff --git a/src/core/deck.rs b/src/core/deck.rs index 74c4343..29c65c8 100644 --- a/src/core/deck.rs +++ b/src/core/deck.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use crate::app::AppState; use crate::core::srs::OrderModule::{Fsrs, RandomSRS, SemiRandomSRS, WorstWordsSRS}; use crate::core::srs::{ @@ -13,6 +14,7 @@ use log::info; use rhai::{Engine, Scope}; use std::sync::Arc; use std::time::Instant; +use crate::data::Id; #[derive(Clone)] pub struct DeckData { @@ -154,10 +156,11 @@ impl DeckData { if let Ok(ast) = ast { let groups = state.word_groups.read().iter().cloned().collect::>(); let dictionary = &state.dictionary.read(); + let fields : HashMap = HashMap::from_iter(state.custom_fields.read().iter().map(|f| (f.id, f.identifier.clone()))); let query = dictionary.query_par(Arc::new(move |word| { let mut more = rhai::Map::new(); - for iced in &word.additional { - more.insert(iced.0.clone().into(), iced.1.clone().into()); + for kv in &word.additional { + more.insert(fields[&kv.0].clone().into(), kv.1.clone().into()); } let mut scope = Scope::new(); let group_name = groups[groups diff --git a/src/data/entities.rs b/src/data/entities.rs index 28da05a..abbed41 100644 --- a/src/data/entities.rs +++ b/src/data/entities.rs @@ -1,5 +1,5 @@ use crate::core::srs::{FADE_PER_DAY, MAX_SCORE, WordOpenMode}; -use crate::data::{INVALID_ID, Id}; +use crate::data::{Id, SmallId, INVALID_ID}; use crate::data_provider::history::HistoryRepositoryInner; use chrono::{DateTime, Local, NaiveDate, Utc}; use fsrs::MemoryState; @@ -19,7 +19,7 @@ pub struct WordData { pub key: String, pub value: String, pub tags: String, - pub additional: LiteMap, + pub additional: LiteMap, } impl EntityData for WordData { @@ -196,12 +196,23 @@ impl CardState { } } +#[derive(Clone)] pub struct CustomField { pub id: Id, pub name: String, pub identifier: String, } +impl EntityData for CustomField { + fn id(&self) -> Id { + self.id + } + + fn set_id(&mut self, id: Id) { + self.id = id; + } +} + pub struct EntityHandle { pub(crate) id: Id, index: u32, @@ -209,7 +220,9 @@ pub struct EntityHandle { } impl Clone for EntityHandle { - fn clone(&self) -> Self { *self } + fn clone(&self) -> Self { + *self + } } impl Copy for EntityHandle {} diff --git a/src/data/repositories.rs b/src/data/repositories.rs index 95553bf..6f6a39e 100644 --- a/src/data/repositories.rs +++ b/src/data/repositories.rs @@ -215,6 +215,10 @@ impl, D: DbLayer> Qu pub fn len(&self) -> usize { self.cache.len() } + + pub fn get(&self, index: usize) -> Option> { + self.cache.get(index).cloned() + } } impl, D: DbLayer> Index diff --git a/src/data_provider/card_states.rs b/src/data_provider/card_states.rs index ef97ecd..1504295 100644 --- a/src/data_provider/card_states.rs +++ b/src/data_provider/card_states.rs @@ -1,6 +1,6 @@ +use crate::data::Id; use crate::data::entities::CardState; use crate::data::repositories::{DataRepository, DataRepositoryInner, Query}; -use crate::data::Id; use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet}; use chrono::NaiveDate; use fsrs::MemoryState; diff --git a/src/data_provider/custom_fields.rs b/src/data_provider/custom_fields.rs index 0b01770..4ea23a9 100644 --- a/src/data_provider/custom_fields.rs +++ b/src/data_provider/custom_fields.rs @@ -6,6 +6,7 @@ use rusqlite::{Error, Params, Row}; pub type CustomFieldRepository = DataRepository; pub type CustomFieldRepositoryInner = DataRepositoryInner; pub type CustomFieldDbLayer = SqliteDbLayer; +#[derive(Clone)] pub struct CustomFieldsQuerySet; impl TableAccessQuerySet for CustomFieldsQuerySet { fn load() -> &'static str { diff --git a/src/data_provider/history.rs b/src/data_provider/history.rs index 7b86513..1a14eba 100644 --- a/src/data_provider/history.rs +++ b/src/data_provider/history.rs @@ -48,7 +48,7 @@ impl TableAccessQuerySet for HistoryQuerySet { ( entity.deck_id, entity.word_id, - entity.timestamp, + entity.timestamp.timestamp(), entity.answer, entity.before, entity.after, @@ -60,7 +60,7 @@ impl TableAccessQuerySet for HistoryQuerySet { ( entity.deck_id, entity.word_id, - entity.timestamp, + entity.timestamp.timestamp(), entity.answer, entity.before, entity.after, diff --git a/src/data_provider/import.rs b/src/data_provider/import.rs index bf59d9b..9c12011 100644 --- a/src/data_provider/import.rs +++ b/src/data_provider/import.rs @@ -5,6 +5,7 @@ use log::info; use rusqlite::Connection; use serde_json::Value; use std::collections::BTreeMap; +use crate::pages::import::AnkiSchema; #[derive(Clone)] pub struct ImportData(pub(crate) Vec); @@ -25,7 +26,18 @@ pub struct ImportNote { pub fields: Vec, } -pub fn load_groups(connection: &Connection) -> Result { +pub fn load_groups(connection: &Connection, schema: AnkiSchema) -> Result { + match schema { + AnkiSchema::Old => { + load_old_schema(connection) + } + AnkiSchema::Beta => { + load_new_schema(connection) + } + } +} + +fn load_old_schema(connection: &Connection) -> Result { let json_models: String = connection .query_one("select models from col", (), |row| row.get(0)) .map_err(|_| IncorrectAnkiFile)?; @@ -82,7 +94,9 @@ pub fn load_groups(connection: &Connection) -> Result Result { + return Err(IncorrectAnkiFile); +} pub fn get_words_of_group( connection: &Connection, group_id: u64, diff --git a/src/data_provider/mod.rs b/src/data_provider/mod.rs index 0979afc..d769188 100644 --- a/src/data_provider/mod.rs +++ b/src/data_provider/mod.rs @@ -107,12 +107,10 @@ impl> DbLayer for SqliteDbLayer Result<(), rusqlite::Error> { fn make_custom_fields(conn: &Connection) -> Result<(), rusqlite::Error> { let query = include_str!("../../sql/custom_fields.sql"); conn.execute(query, ())?; + conn.execute("insert into custom_fields (name, identifier) values (\"Чтение\", \"reading\"), (\"Описание\", \"description\"), (\"Пример использования\", \"context\")", ())?; Ok(()) } diff --git a/src/data_provider/words.rs b/src/data_provider/words.rs index e147dff..75f049f 100644 --- a/src/data_provider/words.rs +++ b/src/data_provider/words.rs @@ -1,6 +1,8 @@ use crate::data::entities::WordData; use crate::data::repositories::{DataRepository, DataRepositoryInner}; +use crate::data::Id; use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet}; +use litemap::LiteMap; use rusqlite::{Error, Params, Row}; pub type WordRepository = DataRepository; @@ -32,7 +34,7 @@ impl TableAccessQuerySet for WordQuerySet { key: row.get(1)?, value: row.get(2)?, tags: row.get(3)?, - additional: serde_json::from_str(&additional).unwrap_or_default(), + additional: additional_from_string(additional), group_id: row.get(5)?, }) } @@ -42,7 +44,7 @@ impl TableAccessQuerySet for WordQuerySet { &entity.key, &entity.value, &entity.tags, - serde_json::to_string(&entity.additional).unwrap_or_default(), + additional_to_string(&entity.additional), entity.group_id, ) } @@ -52,8 +54,42 @@ impl TableAccessQuerySet for WordQuerySet { &entity.key, &entity.value, &entity.tags, - serde_json::to_string(&entity.additional).unwrap_or_default(), + additional_to_string(&entity.additional), entity.id, ) } -} \ No newline at end of file +} + +fn additional_to_string(map: &LiteMap) -> String { + let mut parts = Vec::with_capacity(map.len() * 2); + for (id, value) in map { + parts.push(id.to_string()); + parts.push(value.clone()); + } + parts.join("") +} + +fn additional_from_string_temp(data: String) -> LiteMap { + + let pre_map : LiteMap = serde_json::from_str(data.as_str()).unwrap(); + LiteMap::from(pre_map.iter().map(|(k, v)| (match k.as_str() { + "reading" => Id(1), + "description" => Id(2), + "context" => Id(3), + &_ => Id(0) }, v.to_string())).collect()) +} + +fn additional_from_string(data: String) -> LiteMap { + if data.is_empty() { + return LiteMap::new() + } + let parts = data.split('').collect::>(); + let chunks = parts.chunks(2).collect::>(); + let mut map = LiteMap::with_capacity(parts.len()); + for part in &chunks { + let id : Id = part[0].parse().unwrap(); + let value: String = part[1].to_string(); + map.insert(id, value); + } + map +} diff --git a/src/main.rs b/src/main.rs index 58e8c7a..633e50d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ pub mod notification; mod pages; mod styling; pub mod tests; +pub mod widgets; use crate::app::start; use mimalloc::MiMalloc; diff --git a/src/navigation.rs b/src/navigation.rs index be23c81..05c9600 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -21,7 +21,6 @@ use crate::pages::repetition_settings::{RepetitionSettingsMessage, RepetitionSet use crate::pages::repetitions::{RepetitionsMessage, RepetitionsState}; use crate::pages::selector::{SelectorMessage, SelectorState}; use crate::pages::sync::{SyncMessage, SyncState}; -use crate::pages::word::{WordMessage, WordState}; use crate::pages::writing::{WritingMessage, WritingState}; use crate::state_navigate_handle; use crate::state_update; @@ -56,7 +55,6 @@ pub enum RootMessage { Randomizer(RandomizerMessage), Repetitions(RepetitionsMessage), Repetition(RepetitionMessage), - Word(WordMessage), Sync(SyncMessage), History(HistoryMessage), RepetitionSettings(RepetitionSettingsMessage), @@ -78,7 +76,6 @@ pub enum Page { Randomizer(RandomizerState), Repetitions(RepetitionsState), Repetition(RepetitionState), - Word(WordState), Sync(SyncState), History(HistoryState), RepetitionSettings(RepetitionSettingsState), @@ -182,7 +179,6 @@ impl ScreenState { Randomizer, Repetitions, Repetition, - Word, Sync, History, RepetitionSettings, @@ -206,7 +202,6 @@ impl ScreenState { Randomizer, Repetitions, Repetition, - Word, Sync, History, RepetitionSettings, @@ -227,7 +222,6 @@ impl ScreenState { Randomizer, Repetitions, Repetition, - Word, Sync, History, RepetitionSettings, @@ -244,6 +238,7 @@ impl ScreenState { if let Some(page) = self.stack.last_mut() { return match page { Repetition(page) => page.press(&message), + Dictionary(page) => page.press(&message), _ => Task::none(), }; } diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index 3470630..e8b54e9 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -1,29 +1,38 @@ use crate::app::AppState; -use crate::data::Id; use crate::data::entities::{EntityHandle, WordData, WordGroup}; use crate::data::repositories::{Query, Repository}; +use crate::data::Id; use crate::data_provider::word_groups::{WordGroupDataLayer, WordGroupRepositoryInner}; use crate::data_provider::words::WordDataLayer; use crate::data_provider::words::WordRepositoryInner; -use crate::navigation::Page::{Import, Word}; -use crate::navigation::{NavigatedPage, Page, RootMessage}; +use crate::navigation::Page::Import; +use crate::navigation::{KeyPressedPage, NavigatedPage, Page, RootMessage}; +use crate::pages::dictionary::BasicWordFields::*; use crate::pages::dictionary::DictionaryMessage::*; +use crate::pages::dictionary::SelectionChangeDirection::*; use crate::pages::dictionary_quiz::DictionaryQuizState; use crate::pages::import::ImportState; -use crate::pages::word::WordState; +use crate::pages::word::{WordEditor, WordMessage}; use crate::push_error_task; use crate::styling::*; +use crate::widgets::hard_mouse_area::hard_mouse_area; use crate::{jl_button, push_error}; use chrono::{DateTime, TimeDelta, Utc}; use hashbrown::{HashMap, HashSet}; use iced::alignment::Vertical::Center; use iced::widget::button::Style; use iced::widget::button::{danger, text}; +use iced::widget::operation::focus; use iced::widget::space::horizontal; use iced::widget::text_input::default; +use iced::widget::text_input::Status::Active; use iced::widget::*; use iced::{Border, Color, Shadow, Task}; -use iced_core::Length::Fill; +use iced_core::keyboard; +use iced_core::keyboard::key::Physical::Code; +use iced_core::keyboard::{Event, Modifiers}; +use iced_core::keyboard::Key::Named; +use iced_core::Length::{Fill, FillPortion}; use log::info; use rand::random_range; use std::ops::Add; @@ -43,15 +52,15 @@ pub struct DictionaryState { auto_save_queue: HashMap, DateTime>, displayed_words_query: Query, groups_query: Query, + selected_cell: Option<(EntityHandle, BasicWordFields, usize)>, + word_editor: Option>, } #[derive(Clone)] pub enum DictionaryMessage { Back, - SetTags(EntityHandle, String), - SetKey(EntityHandle, String), - SetValue(EntityHandle, String), - WordAction(EntityHandle), + ExtendedEdit(EntityHandle), + DeleteWord(EntityHandle), NewWord, Include(EntityHandle, bool), IncludeTag(String, bool), @@ -68,8 +77,30 @@ pub enum DictionaryMessage { ChangeDirection, TrySave(EntityHandle), GotoImport, + SelectField(EntityHandle, BasicWordFields, usize), + EditField(String), + ChangeSelectedField(SelectionChangeDirection), + EditorAction(WordMessage), + ExtendedWordEdited } +#[derive(Clone, PartialEq, Eq, Hash, Copy)] +pub enum SelectionChangeDirection { + Next, + Previous, + Forward, + Backward, +} + +#[derive(Clone, PartialEq, Eq, Hash, Copy)] +pub enum BasicWordFields { + Key, + Value, + Tags, +} + +const FIELDS: &[BasicWordFields] = &[Key, Value, Tags]; + impl NavigatedPage for DictionaryState { fn navigate(&mut self, message: &DictionaryMessage) -> Option { if let Back = message { @@ -92,11 +123,7 @@ impl NavigatedPage for DictionaryState { self.state.clone(), ))); } - if let WordAction(handle) = message - && handle.real() - { - return Some(Word(WordState::new(*handle, self.state.clone()))); - } + if let GotoImport = message { return Some(Import(ImportState::new(self.state.clone()))); } @@ -121,49 +148,11 @@ impl NavigatedPage for DictionaryState { dictionary.create(word); self.displayed_words_query.load(&dictionary); } - - SetKey(handle, v) => { - let mut dictionary = self.state.dictionary.write(); - dictionary.get_mut_static(handle).unwrap().key = v; - drop(dictionary); - return self.launch_auto_save_offset(handle); - } - SetValue(handle, v) => { - let mut dictionary = self.state.dictionary.write(); - dictionary.get_mut_static(handle).unwrap().value = v; - drop(dictionary); - return self.launch_auto_save_offset(handle); - } - SetTags(i, mut v) => { - { - let mut dictionary = self.state.dictionary.write(); - - let word = dictionary.get_mut_static(i).unwrap(); - let current_tags_value = word.tags.clone(); - - if v.ends_with(", ") && v.len() < current_tags_value.len() { - v = v[..v.len() - 2].to_string() - } - - while let Some(index) = v.find(",,") { - v.remove(index); - } - - word.tags = v; + ExtendedEdit(i) => { + self.word_editor = Some(self.editor_for_word(i)); + if let Some((handle, cell, index)) = self.selected_cell.clone() { + self.selected_cell = Some((i, cell, index)) } - - self.update_tags(); - return self.launch_auto_save_offset(i); - } - - WordAction(i) => { - let mut dictionary = self.state.dictionary.write(); - - if let Err(err) = dictionary.delete(i) { - return push_error_task!(err); - } - - self.displayed_words_query.load(&dictionary); } Include(i, b) => { if b { @@ -241,18 +230,21 @@ impl NavigatedPage for DictionaryState { } self.groups_query.load(&groups); } - + self.selected_cell = None; + self.word_editor = None; self.update_tags() } ChangeDirection => { self.reverse_list = !self.reverse_list; } TrySave(mut handle) => { + let original = handle; let now = Utc::now(); if !self.auto_save_queue.contains_key(&handle) { return Task::none(); } if self.auto_save_queue[&handle] <= now { + self.update_tags(); self.auto_save_queue.remove(&handle); let mut dictionary = self.state.dictionary.write(); @@ -261,11 +253,122 @@ impl NavigatedPage for DictionaryState { let _ = dictionary.save(&mut handle); if reload_query { self.displayed_words_query.load(&dictionary); + if let Some(selected) = self.selected_cell && selected.0 == original { + self.selected_cell = Some((handle, selected.1, selected.2)); + if self.word_editor.is_some() { + self.word_editor = Some(self.editor_for_word(handle)); + } + } } + info!("Saved word"); return Task::none(); } } GotoImport => {} + SelectField(e, f, index) => { + info!("Editing word {}", e.id); + if self.word_editor.is_some() && let Some(selected) = &self.selected_cell && selected.0 != e { + self.word_editor = Some(self.editor_for_word(e)); + } + self.selected_cell = Some((e, f, index)); + + return focus(iced::widget::Id::new("editor")); + } + EditField(value) => { + if let Some(selected) = self.selected_cell.as_ref() { + { + let mut dictionary = self.state.dictionary.write(); + let word = dictionary.get_mut_static(selected.0).unwrap(); + match selected.1 { + Key => { + word.key = value; + } + Value => { + word.value = value; + } + Tags => { + word.tags = value; + } + } + } + + return self.launch_auto_save_offset(selected.0); + } + } + ChangeSelectedField(change) => { + return if let Some(selected) = self.selected_cell { + let update_vertical = move |mut dir: bool| { + if self.reverse_list { + dir = !dir; + } + let new_index = if dir { + selected.2.saturating_add(1) + } else { + selected.2.saturating_sub(1) + }; + if new_index < self.displayed_words_query.len() { + return SelectField( + self.displayed_words_query.get(new_index).unwrap(), + selected.1, + new_index, + ); + } + + SelectField(selected.0, selected.1, selected.2) + }; + + let update_horizontal = move |dir: bool| { + let current = selected.1; + let delta = if dir { 1 } else { -1 }; + let mut index = + FIELDS.iter().position(|&f| f == current).unwrap() as i8 + delta; + if index < 0 { + index = (FIELDS.len() - 1) as i8; + } + if index >= FIELDS.len() as i8 { + index = 0; + } + SelectField(selected.0, FIELDS[index as usize], selected.2) + }; + + Task::done(RootMessage::Dictionary(match change { + Next => update_vertical(true), + Previous => update_vertical(false), + Forward => update_horizontal(true), + Backward => update_horizontal(false), + })) + } else { + Task::none() + }; + } + DeleteWord(i) => { + let mut dictionary = self.state.dictionary.write(); + + if let Err(err) = dictionary.delete(i) { + return push_error_task!(err); + } + + self.displayed_words_query.load(&dictionary); + self.selected_cell = None; + self.word_editor = None; + + } + EditorAction(action) => { + let result = self.word_editor.as_mut().unwrap().update(action); + if let Some(result) = result { + match result { + DeleteWord(_) | + ExtendedWordEdited => { + return Task::done(RootMessage::Dictionary(result)); + }, + + _ => {} + } + } + } + ExtendedWordEdited => { + return self.launch_auto_save_offset(self.selected_cell.unwrap().0); + } } Task::none() @@ -277,14 +380,15 @@ impl NavigatedPage for DictionaryState { iced::widget::column![ self.groups_panel(), row![horizontal().width(8), self.words_list(),], + self.selection_editor(), row![ self.add_word_button(), horizontal().width(Fill), button("Импорт").style(text).on_press(GotoImport), ] ] - .spacing(5), - self.filters(), + .spacing(5).width(FillPortion(4)), + self.right_panel(), ] .spacing(DEFAULT_SPACING) .into(), @@ -310,6 +414,8 @@ impl DictionaryState { auto_save_queue: HashMap::new(), displayed_words_query: words_query, groups_query, + selected_cell: None, + word_editor: None, }; result.init(); @@ -318,6 +424,10 @@ impl DictionaryState { result } + fn editor_for_word(&self, handle: EntityHandle) -> WordEditor { + WordEditor::new(handle, self.state.clone()).on_delete(DeleteWord(handle)).on_edit(ExtendedWordEdited) + } + fn init(&mut self) { self.displayed_words_query = self .state @@ -336,11 +446,11 @@ impl DictionaryState { } fn launch_auto_save_offset(&mut self, handle: EntityHandle) -> Task { - let save_time = Utc::now().add(TimeDelta::milliseconds(900)); + let save_time = Utc::now().add(TimeDelta::milliseconds(500)); self.auto_save_queue.insert(handle, save_time); let message = RootMessage::Dictionary(TrySave(handle)); Task::perform( - async { tokio::time::sleep(Duration::from_secs(1)).await }, + async { tokio::time::sleep(Duration::from_millis(520)).await }, |_| message, ) } @@ -368,12 +478,22 @@ impl DictionaryState { continue; } + let mut selection = None; + + if let Some(cell) = self.selected_cell.as_ref() + && cell.0 == *word_handle + { + selection = Some(cell.1); + } + let word_line_data = WordLineState { is_included: self.included.contains(word_handle), key: word.key.clone().into(), value: word.value.clone().into(), tags: word.tags.clone().into(), handle: *word_handle, + selection, + number: i, }; let lazy_line = lazy(word_line_data, Self::word_line); @@ -395,45 +515,51 @@ impl DictionaryState { .on_toggle(move |b| Include(handle, b)), ); - line = line.push( - text_input("Слово", &data.key) + let field = |placeholder: &str, data: &str, selected: bool| { + text_input(placeholder, data) .size(ACCENT_FONT_SIZE) .width(Fill) - .on_input(move |string| SetKey(handle, string)) - .style(|x, status| { - let mut default_style = default(x, status); + .style(move |x, _| { + let mut default_style = default(x, Active); default_style.border.radius = 0.0.into(); + if selected { + default_style.border.color = x.extended_palette().secondary.base.color; + default_style.border.width = 3.0_f32; + } default_style - }), + }) + }; + + line = line.push( + hard_mouse_area(field( + "Слово", + &data.key, + data.selection.is_some() && data.selection.unwrap() == Key, + )) + .on_press(SelectField(data.handle, Key, data.number)), ); line = line.push( - text_input("Перевод", &data.value) - .size(ACCENT_FONT_SIZE) - .width(Fill) - .on_input(move |string| SetValue(handle, string)) - .style(|x, status| { - let mut default_style = default(x, status); - default_style.border.radius = 0.0.into(); - default_style - }), + hard_mouse_area(field( + "Перевод", + &data.value, + data.selection.is_some() && data.selection.unwrap() == Value, + )) + .on_press(SelectField(data.handle, Value, data.number)), ); line = line.push( - text_input("Теги", data.tags.as_ref()) - .size(ACCENT_FONT_SIZE) - .width(Fill) - .on_input(move |string| SetTags(handle, string)) - .style(|x, status| { - let mut default_style = default(x, status); - default_style.border.radius = 0.0.into(); - default_style - }), + hard_mouse_area(field( + "Теги", + &data.tags, + data.selection.is_some() && data.selection.unwrap() == Tags, + )) + .on_press(SelectField(data.handle, Tags, data.number)), ); let line_button = || { if !data.handle.real() { return button("-") - .on_press(WordAction(data.handle)) + .on_press(DeleteWord(data.handle)) .style(|_x: &Theme, _status: button::Status| Style { background: None, text_color: Color::BLACK, @@ -445,13 +571,21 @@ impl DictionaryState { } button("") - .on_press(WordAction(data.handle)) + .on_press(ExtendedEdit(data.handle)) .style(button::primary) .width(15) }; line.push(line_button()).push(space().width(10)) } + fn right_panel(&self) -> iced::Element<'_, DictionaryMessage> { + if let Some(editor) = &self.word_editor { + container(editor.view().map(EditorAction)).width(FillPortion(3)).into() + } else { + self.filters() + } + } + fn filters(&self) -> iced::Element<'_, DictionaryMessage> { let dict = &self.state.dictionary.read(); @@ -473,7 +607,7 @@ impl DictionaryState { .on_press(GotoTest) .width(Fill), ] - .width(250) + .width(FillPortion(1)) .spacing(DEFAULT_SPACING) .into() } @@ -573,6 +707,31 @@ impl DictionaryState { info!("Time {}", time.elapsed().as_micros()); } + fn selection_editor(&self) -> iced::Element<'_, DictionaryMessage> { + let mut display = "".to_string(); + if let Some(selected) = self.selected_cell.as_ref() { + let mut dictionary = self.state.dictionary.write(); + let word = dictionary.get_mut_static(selected.0).unwrap(); + + match selected.1 { + Key => { + display = word.key.clone(); + } + Value => { + display = word.value.clone(); + } + Tags => { + display = word.tags.clone(); + } + } + } + text_input("", display.as_str()) + .width(Fill) + .id(iced::widget::Id::new("editor")) + .on_input(EditField) + .into() + } + fn groups_panel(&self) -> iced::Element<'_, DictionaryMessage> { let mut row = Row::new(); row = row.push(button("+").style(text).on_press(CreateGroup)); @@ -626,6 +785,52 @@ impl DictionaryState { } } +impl KeyPressedPage for DictionaryState { + fn press(&mut self, message: &Event) -> Task { + if let Event::KeyPressed { + key: _, + modified_key: modifier, + physical_key: pk, + location: _, + modifiers: mods, + text: _, + repeat: _, + } = message + && let Code(code) = pk + { + return match code { + keyboard::key::Code::ArrowDown => { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Next))) + } + keyboard::key::Code::ArrowUp => { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Previous))) + } + keyboard::key::Code::Tab => { + return if *mods == Modifiers::CTRL && let Some(selected) = self.selected_cell { + Task::done(RootMessage::Dictionary(ExtendedEdit(selected.0))) + } else if let Named(keyboard::key::Named::Alt) = modifier { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Backward))) + } else { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Forward))) + } + } + keyboard::key::Code::Escape => { + if self.word_editor.is_some() { + self.word_editor = None; + }else if self.selected_cell.is_some() { + self.selected_cell = None; + } + + Task::none() + } + _ => Task::none(), + }; + } + + Task::none() + } +} + pub fn split_with_coma(ts: &str) -> Vec { ts.split(',') .map(|ts| ts.to_lowercase().trim().to_string()) @@ -636,8 +841,10 @@ pub fn split_with_coma(ts: &str) -> Vec { #[derive(Clone, Hash, PartialEq, Eq)] struct WordLineState { is_included: bool, - key: Box, - value: Box, - tags: Box, + key: String, + value: String, + tags: String, handle: EntityHandle, + selection: Option, + number: usize, } diff --git a/src/pages/dictionary_quiz.rs b/src/pages/dictionary_quiz.rs index 129dc98..dbfb34d 100644 --- a/src/pages/dictionary_quiz.rs +++ b/src/pages/dictionary_quiz.rs @@ -9,11 +9,11 @@ use crate::pages::dictionary::split_with_coma; use crate::pages::dictionary_quiz::DictionaryQuizMessage::*; use crate::pages::quiz::Score; use crate::styling::*; +use iced::Background::Color; use iced::border::Radius; use iced::widget::container::Style; -use iced::widget::{container, row, space, text, text_input, Row}; -use iced::Background::Color; -use iced::{alignment, Border, Element, Fill, Task, Theme}; +use iced::widget::{Row, container, row, space, text, text_input}; +use iced::{Border, Element, Fill, Task, Theme, alignment}; use rand::prelude::SliceRandom; pub struct DictionaryQuizState { diff --git a/src/pages/history.rs b/src/pages/history.rs index 382e9d6..7bcc6b6 100644 --- a/src/pages/history.rs +++ b/src/pages/history.rs @@ -64,9 +64,7 @@ impl HistoryState { (word, item.clone()) }) .collect(); - Self { - list: words, - } + Self { list: words } } fn history_lines(&self) -> Column<'_, HistoryMessage> { diff --git a/src/pages/import.rs b/src/pages/import.rs index 6633340..0de44c1 100644 --- a/src/pages/import.rs +++ b/src/pages/import.rs @@ -1,16 +1,17 @@ use crate::app::AppState; -use crate::data::entities::WordData; +use crate::data::Id; use crate::data::entities::WordGroup; +use crate::data::entities::{CustomField, WordData}; use crate::data::repositories::{BatchedRepository, Repository}; use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups}; use crate::fs::*; -use crate::jl_button; use crate::navigation::{NavigatedPage, Page, RootMessage}; -use crate::notification::NotificationData::OpenFileError; +use crate::notification::NotificationData::{CompressError, OpenFileError}; use crate::notification::{MaybeError, NotificationData}; use crate::pages::import::ImportMessage::*; use crate::push_error; use crate::styling::*; +use crate::{jl_button, push_error_task}; use iced::widget::button::danger; use iced::widget::container::success; use iced::widget::{ @@ -25,12 +26,14 @@ use log::info; use parking_lot::Mutex; use rfd::AsyncFileDialog; use rusqlite::Connection; +use std::collections::HashMap; use std::fs::{File, OpenOptions}; use std::io::BufReader; use std::path::PathBuf; use std::sync::Arc; use tokio::task::spawn_blocking; use zip::ZipArchive; +use zstd::Decoder; const DEFAULT_FIELDS: [&str; 6] = ["key", "value", "tags", "reading", "context", "description"]; @@ -360,8 +363,8 @@ impl ImportState { Task::perform( async { spawn_blocking(move || { - Self::extract_import_file(path)?; - let data = Self::read_import_file()?; + let schema = Self::extract_import_file(path)?; + let data = Self::read_import_file(schema)?; Ok(data) }) .await @@ -374,7 +377,7 @@ impl ImportState { ) } - fn extract_import_file(path: PathBuf) -> MaybeError { + fn extract_import_file(path: PathBuf) -> Result { let file = File::open(path).map_err(|_| OpenFileError)?; let reader = BufReader::new(file); let mut archive = ZipArchive::new(reader).map_err(|_| OpenFileError)?; @@ -385,20 +388,24 @@ impl ImportState { .truncate(true) .open(&temp_file_path) .map_err(|_| OpenFileError)?; - if let Ok(mut file) = archive.by_name("collection.anki21") { + if let Ok(mut file) = archive.by_name("collection.anki21b") { + let mut decoder = Decoder::new(&mut file).map_err(|_| CompressError)?; + std::io::copy(&mut decoder, &mut temp_file).map_err(|_| OpenFileError)?; + Ok(AnkiSchema::Beta) + } else if let Ok(mut file) = archive.by_name("collection.anki21") { std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?; - Ok(()) + Ok(AnkiSchema::Old) } else if let Ok(mut file) = archive.by_name("collection.anki2") { std::io::copy(&mut file, &mut temp_file).map_err(|_| OpenFileError)?; - Ok(()) + Ok(AnkiSchema::Old) } else { Err(OpenFileError) } } - fn read_import_file() -> Result { + fn read_import_file(schema: AnkiSchema) -> Result { let temp_file_path = temp_import_path(); let connection = Connection::open(&temp_file_path).map_err(|_| OpenFileError)?; - let data = load_groups(&connection)?; + let data = load_groups(&connection, schema)?; Ok(data) } fn setup_mapping(&mut self, property_name: String) { @@ -428,7 +435,19 @@ impl ImportState { let separator = self.separator.clone(); let skip_empty = self.skip_empty; let group = self.import_data.as_ref().unwrap().0[self.selected_index].clone(); + let map_indices = Self::get_mapping_indices(&group); let progress = self.progress.clone().unwrap(); + if let Err(err) = Self::ensure_fields(&state, &map_indices) { + return push_error_task!(err); + } + let fields: HashMap = HashMap::from_iter( + state + .custom_fields + .read() + .iter() + .map(|f| (f.identifier.clone(), f.id)), + ); + info!("{}", group.name); Task::perform( async move { @@ -439,7 +458,6 @@ impl ImportState { let import_list = get_words_of_group(&connection, group.id)?; let mut words_list = Vec::with_capacity(import_list.len()); - let map_indices = Self::get_mapping_indices(&group); let group_handle; { let group_entity = WordGroup { @@ -474,7 +492,7 @@ impl ImportState { word.tags = collected_string; } &_ => { - word.additional.insert((*dest).clone(), collected_string); + word.additional.insert(fields[dest], collected_string); } } } @@ -554,4 +572,33 @@ impl ImportState { |_| RootMessage::Import(NextProgress), ) } + + fn ensure_fields( + state: &AppState, + import_group: &Vec<(String, Vec)>, + ) -> Result<(), NotificationData> { + let mut fields_repo = state.custom_fields.write(); + let mut current_fields: Vec = fields_repo + .iter() + .map(|field| field.identifier.clone()) + .collect(); + current_fields.append(&mut vec!["key".to_string(), "value".to_string(), "tags".to_string()]); + for (field, _) in import_group { + if current_fields.contains(field) { + continue; + } + + fields_repo.create_now(CustomField { + id: Default::default(), + name: field.clone(), + identifier: field.clone(), + })?; + } + + Ok(()) + } } + +pub enum AnkiSchema { + Old, Beta +} \ No newline at end of file diff --git a/src/pages/repetition.rs b/src/pages/repetition.rs index fea90c2..369c41c 100644 --- a/src/pages/repetition.rs +++ b/src/pages/repetition.rs @@ -262,19 +262,19 @@ impl RepetitionState { } fn draw_reading(&self, word: &WordData) -> Element<'_, RepetitionMessage> { - match word.additional.get("reading") { + match word.additional.get(&Id(1)) { None => space().into(), Some(reading) => text!("{}", reading).size(24).into(), } } fn draw_context(&self, word: &WordData) -> Element<'_, RepetitionMessage> { - match word.additional.get("context") { + match word.additional.get(&Id(3)) { None => space().into(), Some(context) => text!("{}", context).size(24).into(), } } fn draw_description(&self, word: &WordData) -> Element<'_, RepetitionMessage> { - match word.additional.get("description") { + match word.additional.get(&Id(2)) { None => space().into(), Some(context) => text!("{}", context).size(24).into(), } diff --git a/src/pages/repetitions.rs b/src/pages/repetitions.rs index d33cbf3..ecf17fe 100644 --- a/src/pages/repetitions.rs +++ b/src/pages/repetitions.rs @@ -16,17 +16,17 @@ use crate::push_error_task; use crate::styling::*; use chrono::{Days, Local, Utc}; use hashbrown::HashSet; -use iced::widget::button::{danger, Status}; pub use iced::widget::button::{Catalog, Style}; +use iced::widget::button::{Status, danger}; use iced::widget::tooltip::Position::Top; use iced::widget::{ - button, column, container, lazy, radio, row, scrollable, space, svg, text, text_input, tooltip, - Column, Row, + Column, Row, button, column, container, lazy, radio, row, scrollable, space, svg, text, + text_input, tooltip, }; use iced::{Background, Border, Center, Color, Element, Fill, Length, Shadow, Task, Theme}; +use iced_core::Padding; use iced_core::border::Radius; use iced_core::svg::Handle; -use iced_core::Padding; use std::collections::BTreeMap; use std::ops::Deref; use std::sync::Arc; diff --git a/src/pages/selector.rs b/src/pages/selector.rs index 825a16f..fe74632 100644 --- a/src/pages/selector.rs +++ b/src/pages/selector.rs @@ -13,7 +13,7 @@ use crate::pages::writing::WritingState; use crate::styling::*; use iced::widget::button::{Status, Style}; use iced::widget::*; -use iced::{alignment, Element, Task}; +use iced::{Element, Task, alignment}; use iced_core::Alignment::Center; use iced_core::Background; use iced_core::Color; diff --git a/src/pages/word.rs b/src/pages/word.rs index 6232f66..3793721 100644 --- a/src/pages/word.rs +++ b/src/pages/word.rs @@ -1,173 +1,150 @@ use crate::app::AppState; -use crate::data::entities::{EntityHandle, WordData}; -use crate::navigation::Page::PreviousPage; -use crate::navigation::{NavigatedPage, Page, RootMessage}; +use crate::data::Id; +use crate::data::entities::{CustomField, EntityHandle, WordData}; +use crate::data::repositories::Repository; use crate::pages::word::WordMessage::*; use crate::styling::*; use iced::widget::button::danger; -use iced::widget::{button, column, row, space, text, text_input}; -use iced::{Element, Fill, Task}; +use iced::widget::space::horizontal; +use iced::widget::{button, column, row, rule, scrollable, text, text_input}; +use iced::{Element, Fill}; +use iced_core::Padding; -pub struct WordState { +pub struct WordEditor { state: AppState, handle: EntityHandle, + on_delete: Option, + on_edit: Option, + fields: Vec, } -impl NavigatedPage for WordState { - fn navigate(&mut self, message: &WordMessage) -> Option { - if let Back = message { - Some(PreviousPage) - } else { - None +impl WordEditor { + pub(crate) fn update(&mut self, message: WordMessage) -> Option { + let dictionary = &mut self.state.dictionary.write(); + let word = dictionary.get_mut(&mut self.handle).unwrap(); + match message { + Delete => { + return self.on_delete.take(); + } + SetTags(n) => word.tags = n, + SetKey(n) => { + word.key = n; + } + SetValue(n) => { + word.value = n; + } + SetAdditional(key, value) => { + word.additional.insert(key, value.clone()); + } + AddAdditional(key) => { + word.additional.insert(key, "".to_string()); + } + RemoveAdditional(key) => { + word.additional.remove(&key); + } } + self.on_edit.take() } - fn navigated(&mut self) {} - fn update(&mut self, message: WordMessage) -> Task { - // match message { - // Back => {} - // Save => { - // let mut state = self.state.lock(); - // state.dictionary[self.index] = self.word.clone(); - // - // if let Err(err) = update_word(&mut self.word, &state.connection) { - // return push_error_task!(err); - // } - // return Task::done(RootMessage::Word(Back)); - // } - // Delete => { - // let mut state = self.state.lock(); - // state.dictionary.remove(self.index); - // if let Err(err) = delete_word(&self.word, &state.connection) { - // return push_error_task!(err); - // } - // return Task::done(RootMessage::Word(Back)); - // } - // SetTags(n) => self.word.tags = n, - // SetKey(n) => { - // self.word.key = n; - // } - // SetValue(n) => { - // self.word.value = n; - // } - // SetAdditional(key, value) => { - // self.word.additional.insert(key, value.clone()); - // } - // AddAdditional(key) => { - // self.word.additional.insert(key, "".to_string()); - // } - // RemoveAdditional(key) => { - // self.word.additional.remove(key.as_str()); - // } - // } - Task::none() - } + pub(crate) fn view(&self) -> Element<'_, WordMessage> { + let mut fast_add = row![]; + let word = self + .state + .dictionary + .read() + .get_static(self.handle) + .unwrap(); + for field in &self.fields { + if !word.additional.contains_key(&field.id) { + fast_add = fast_add.push( + button(field.name.as_str()) + .style(button::text) + .on_press(AddAdditional(field.id)), + ); + } + } - fn view(&self) -> Element<'_, WordMessage> { - // let mut fast_add = row![]; - // if !self.word.additional.contains_key("reading") { - // fast_add = fast_add.push( - // button("Чтение") - // .style(button::text) - // .on_press(AddAdditional("reading".to_string())), - // ); - // } - // if !self.word.additional.contains_key("description") { - // fast_add = fast_add.push( - // button("Описание") - // .style(button::text) - // .on_press(AddAdditional("description".to_string())), - // ); - // } - // - // if !self.word.additional.contains_key("context") { - // fast_add = fast_add.push( - // button("В контексте") - // .style(button::text) - // .on_press(AddAdditional("context".to_string())), - // ); - // } - // - // let mut col = iced::widget::column![ - // column![ - // text!("Ключ"), - // text_input("key", &self.word.key).on_input(SetKey), - // ] - // .spacing(QUARTER_SPACING), - // column![ - // text!("Значение"), - // text_input("value", &self.word.value).on_input(SetValue), - // ] - // .spacing(QUARTER_SPACING), - // column![ - // text!("Теги"), - // text_input("tags", &self.word.tags).on_input(SetTags), - // ] - // .spacing(QUARTER_SPACING), - // rule::horizontal(2), - // scrollable(fast_add), - // ]; - // - // for more in &self.word.additional { - // col = col.push(self.get_view_for_more(more)); - // } - // - // back_overlay( - // column![ - // col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill), - // row![ - // button("Сохранить").style(jl_button).on_press(Save), - // button("Удалить").style(danger).on_press(Delete), - // ] - // .spacing(DEFAULT_SPACING) - // ] - // .spacing(DEFAULT_SPACING) - // .into(), - // Back, - // ) - space().into() + let mut col = iced::widget::column![ + column![text!("Ключ"), text_input("key", &word.key).on_input(SetKey),] + .spacing(QUARTER_SPACING), + column![ + text!("Значение"), + text_input("value", &word.value).on_input(SetValue), + ] + .spacing(QUARTER_SPACING), + column![ + text!("Теги"), + text_input("tags", &word.tags).on_input(SetTags), + ] + .spacing(QUARTER_SPACING), + rule::horizontal(2), + scrollable(fast_add.padding(Padding{ + + + top: 0.0 , right: 0.0 , bottom: 4.0 , left: 0.0 , })).horizontal(), + ]; + + for more in &word.additional { + col = col.push(self.get_view_for_more(more)); + } + + column![ + col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill), + row![ + horizontal().width(Fill), + button("Удалить").style(danger).on_press(Delete), + ] + .spacing(DEFAULT_SPACING) + ] + .spacing(DEFAULT_SPACING) + .into() } } -impl WordState { +impl WordEditor { pub fn new(handle: EntityHandle, state: AppState) -> Self { - Self { state, handle } - } -} - -impl WordState { - fn get_view_for_more(&self, value: (&String, &String)) -> Element<'_, WordMessage> { - match value.0.as_str() { - "reading" => self.reading_field(value.1), - "description" => self.description_field(value.1), - "context" => self.context_field(value.1), - _ => self.additional_field(value.1, value.0.clone(), value.0.clone()), + let fields = state + .custom_fields + .read() + .iter() + .cloned() + .collect::>(); + Self { + state, + handle, + on_delete: None, + on_edit: None, + fields, } } - fn reading_field(&self, value: &str) -> Element<'_, WordMessage> { - self.additional_field(value, "Чтение слова".to_string(), "reading".to_string()) + pub fn on_delete(mut self, message: Message) -> Self { + self.on_delete = Some(message); + self + } + pub fn on_edit(mut self, message: Message) -> Self { + self.on_edit = Some(message); + self + } +} + +impl WordEditor { + fn get_view_for_more(&self, value: (&Id, &String)) -> Element<'_, WordMessage> { + let field = self.fields.iter().find(|f| f.id == *value.0).unwrap(); + match value.0 { + _ => self.additional_field(value.1, field.name.as_str(), value.0.clone()), + } } - fn description_field(&self, value: &str) -> Element<'_, WordMessage> { - self.additional_field(value, "Описание".to_string(), "description".to_string()) - } - - fn context_field(&self, value: &str) -> Element<'_, WordMessage> { - self.additional_field(value, "В контексте".to_string(), "context".to_string()) - } - fn additional_field(&self, value: &str, name: String, id: String) -> Element<'_, WordMessage> { + fn additional_field(&self, value: &str, name: &str, id: Id) -> Element<'_, WordMessage> { column![ text!("{}", name), row![ - text_input(id.clone().as_str(), value) - .on_input({ - let value = id.clone(); - move |string| SetAdditional(value.clone(), string) - }) + text_input(name, value) + .on_input(move |string| SetAdditional(id, string)) .width(Fill), button("-") - .on_press(RemoveAdditional(id.clone())) + .on_press(RemoveAdditional(id)) .style(danger), ] .spacing(QUARTER_SPACING), @@ -179,13 +156,11 @@ impl WordState { #[derive(Debug, Clone)] pub enum WordMessage { - Save, - Back, Delete, SetTags(String), SetKey(String), SetValue(String), - AddAdditional(String), - RemoveAdditional(String), - SetAdditional(String, String), + AddAdditional(Id), + RemoveAdditional(Id), + SetAdditional(Id, String), } diff --git a/src/widgets/hard_mouse_area.rs b/src/widgets/hard_mouse_area.rs new file mode 100644 index 0000000..0d9cccb --- /dev/null +++ b/src/widgets/hard_mouse_area.rs @@ -0,0 +1,447 @@ +use iced::widget::MouseArea; +use iced_core::widget::{Operation, Tree, tree}; +use iced_core::{ + Clipboard, Element, Event, Layout, Length, Point, Rectangle, Shell, Size, Vector, Widget, + layout, mouse, overlay, renderer, touch, +}; + +pub fn hard_mouse_area<'a, Message, Theme, Renderer>( + widget: impl Into>, +) -> HardMouseArea<'a, Message, Theme, Renderer> +where + Renderer: iced_core::Renderer, +{ + HardMouseArea::new(widget) +} + +pub struct HardMouseArea<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + content: Element<'a, Message, Theme, Renderer>, + on_press: Option, + on_release: Option, + on_double_click: Option, + on_right_press: Option, + on_right_release: Option, + on_middle_press: Option, + on_middle_release: Option, + on_scroll: Option Message + 'a>>, + on_enter: Option, + on_move: Option Message + 'a>>, + on_exit: Option, + interaction: Option, +} + +impl<'a, Message, Theme, Renderer> HardMouseArea<'a, Message, Theme, Renderer> { + /// The message to emit on a left button press. + #[must_use] + pub fn on_press(mut self, message: Message) -> Self { + self.on_press = Some(message); + self + } + + /// The message to emit on a left button release. + #[must_use] + pub fn on_release(mut self, message: Message) -> Self { + self.on_release = Some(message); + self + } + + /// The message to emit on a double click. + /// + /// If you use this with [`on_press`]/[`on_release`], those + /// event will be emit as normal. + /// + /// The events stream will be: on_press -> on_release -> on_press + /// -> on_double_click -> on_release -> on_press ... + /// + /// [`on_press`]: Self::on_press + /// [`on_release`]: Self::on_release + #[must_use] + pub fn on_double_click(mut self, message: Message) -> Self { + self.on_double_click = Some(message); + self + } + + /// The message to emit on a right button press. + #[must_use] + pub fn on_right_press(mut self, message: Message) -> Self { + self.on_right_press = Some(message); + self + } + + /// The message to emit on a right button release. + #[must_use] + pub fn on_right_release(mut self, message: Message) -> Self { + self.on_right_release = Some(message); + self + } + + /// The message to emit on a middle button press. + #[must_use] + pub fn on_middle_press(mut self, message: Message) -> Self { + self.on_middle_press = Some(message); + self + } + + /// The message to emit on a middle button release. + #[must_use] + pub fn on_middle_release(mut self, message: Message) -> Self { + self.on_middle_release = Some(message); + self + } + + /// The message to emit when scroll wheel is used + #[must_use] + pub fn on_scroll(mut self, on_scroll: impl Fn(mouse::ScrollDelta) -> Message + 'a) -> Self { + self.on_scroll = Some(Box::new(on_scroll)); + self + } + + /// The message to emit when the mouse enters the area. + #[must_use] + pub fn on_enter(mut self, message: Message) -> Self { + self.on_enter = Some(message); + self + } + + /// The message to emit when the mouse moves in the area. + #[must_use] + pub fn on_move(mut self, on_move: impl Fn(Point) -> Message + 'a) -> Self { + self.on_move = Some(Box::new(on_move)); + self + } + + /// The message to emit when the mouse exits the area. + #[must_use] + pub fn on_exit(mut self, message: Message) -> Self { + self.on_exit = Some(message); + self + } + + /// The [`mouse::Interaction`] to use when hovering the area. + #[must_use] + pub fn interaction(mut self, interaction: mouse::Interaction) -> Self { + self.interaction = Some(interaction); + self + } +} + +/// Local state of the [`HardMouseArea`]. +#[derive(Default)] +struct State { + is_hovered: bool, + bounds: Rectangle, + cursor_position: Option, + previous_click: Option, +} + +impl<'a, Message, Theme, Renderer> HardMouseArea<'a, Message, Theme, Renderer> { + /// Creates a [`HardMouseArea`] with the given content. + pub fn new(content: impl Into>) -> Self { + HardMouseArea { + content: content.into(), + on_press: None, + on_release: None, + on_double_click: None, + on_right_press: None, + on_right_release: None, + on_middle_press: None, + on_middle_release: None, + on_scroll: None, + on_enter: None, + on_move: None, + on_exit: None, + interaction: None, + } + } +} + +impl Widget + for HardMouseArea<'_, Message, Theme, Renderer> +where + Renderer: renderer::Renderer, + Message: Clone, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.content + .as_widget_mut() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if self.on_move.is_none() && let Event::Mouse(mouse::Event::CursorMoved {..}) = event { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + if self.on_enter.is_none() && let Event::Mouse(mouse::Event::CursorEntered {..}) = event { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + if self.on_exit.is_none() && let Event::Mouse(mouse::Event::CursorLeft {..}) = event { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + if shell.is_event_captured() { + return; + } + + update(self, tree, event, layout, cursor, shell); + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + let content_interaction = self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ); + + match (self.interaction, content_interaction) { + (Some(interaction), mouse::Interaction::None) if cursor.is_over(layout.bounds()) => { + interaction + } + _ => content_interaction, + } + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a + Clone, + Theme: 'a, + Renderer: 'a + renderer::Renderer, +{ + fn from( + area: HardMouseArea<'a, Message, Theme, Renderer>, + ) -> Element<'a, Message, Theme, Renderer> { + Element::new(area) + } +} + +/// Processes the given [`Event`] and updates the [`State`] of an [`HardMouseArea`] +/// accordingly. +fn update( + widget: &mut HardMouseArea<'_, Message, Theme, Renderer>, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + shell: &mut Shell<'_, Message>, +) { + let state: &mut State = tree.state.downcast_mut(); + + let cursor_position = cursor.position(); + let bounds = layout.bounds(); + + if state.cursor_position != cursor_position || state.bounds != bounds { + let was_hovered = state.is_hovered; + + state.is_hovered = cursor.is_over(layout.bounds()); + state.cursor_position = cursor_position; + state.bounds = bounds; + + match ( + widget.on_enter.as_ref(), + widget.on_move.as_ref(), + widget.on_exit.as_ref(), + ) { + (Some(on_enter), _, _) if state.is_hovered && !was_hovered => { + shell.publish(on_enter.clone()); + } + (_, Some(on_move), _) if state.is_hovered => { + if let Some(position) = cursor.position_in(layout.bounds()) { + shell.publish(on_move(position)); + } + } + (_, _, Some(on_exit)) if !state.is_hovered && was_hovered => { + shell.publish(on_exit.clone()); + } + _ => {} + } + } + + if !cursor.is_over(layout.bounds()) { + return; + } + + match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerPressed { .. }) => { + if let Some(message) = widget.on_press.as_ref() { + shell.publish(message.clone()); + shell.capture_event(); + } + + if let Some(position) = cursor_position + && let Some(message) = widget.on_double_click.as_ref() + { + let new_click = + mouse::Click::new(position, mouse::Button::Left, state.previous_click); + + if new_click.kind() == mouse::click::Kind::Double { + shell.publish(message.clone()); + } + + state.previous_click = Some(new_click); + + // Even if this is not a double click, but the press is nevertheless + // processed by us and should not be popup to parent widgets. + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + | Event::Touch(touch::Event::FingerLifted { .. }) => { + if let Some(message) = widget.on_release.as_ref() { + shell.publish(message.clone()); + } + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => { + if let Some(message) = widget.on_right_press.as_ref() { + shell.publish(message.clone()); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Right)) => { + if let Some(message) = widget.on_right_release.as_ref() { + shell.publish(message.clone()); + } + } + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)) => { + if let Some(message) = widget.on_middle_press.as_ref() { + shell.publish(message.clone()); + shell.capture_event(); + } + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)) => { + if let Some(message) = widget.on_middle_release.as_ref() { + shell.publish(message.clone()); + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) => { + if let Some(on_scroll) = widget.on_scroll.as_ref() { + shell.publish(on_scroll(*delta)); + shell.capture_event(); + } + } + _ => {} + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs new file mode 100644 index 0000000..63b5cf6 --- /dev/null +++ b/src/widgets/mod.rs @@ -0,0 +1 @@ +pub mod hard_mouse_area;