From 47bc83d4ae5d753e014283937baff85a51e103fd Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 00:01:19 +0300 Subject: [PATCH 01/13] New edit process --- src/data/entities.rs | 4 +- src/data_provider/card_states.rs | 2 +- src/data_provider/mod.rs | 10 +- src/data_provider/words.rs | 2 +- src/main.rs | 1 + src/pages/dictionary.rs | 143 ++++++++-- src/pages/dictionary_quiz.rs | 6 +- src/pages/history.rs | 4 +- src/pages/repetitions.rs | 8 +- src/pages/selector.rs | 2 +- src/widgets/hard_mouse_area.rs | 447 +++++++++++++++++++++++++++++++ src/widgets/mod.rs | 1 + 12 files changed, 584 insertions(+), 46 deletions(-) create mode 100644 src/widgets/hard_mouse_area.rs create mode 100644 src/widgets/mod.rs diff --git a/src/data/entities.rs b/src/data/entities.rs index 28da05a..dcedb50 100644 --- a/src/data/entities.rs +++ b/src/data/entities.rs @@ -209,7 +209,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_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/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 for WordQuerySet { entity.id, ) } -} \ No newline at end of file +} 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/pages/dictionary.rs b/src/pages/dictionary.rs index 3470630..1e96357 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -13,13 +13,16 @@ use crate::pages::import::ImportState; use crate::pages::word::WordState; 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::Status::Active; use iced::widget::text_input::default; use iced::widget::*; use iced::{Border, Color, Shadow, Task}; @@ -43,6 +46,8 @@ pub struct DictionaryState { auto_save_queue: HashMap, DateTime>, displayed_words_query: Query, groups_query: Query, + selected_cell: Option<(EntityHandle, BasicWordFields)>, + hovered_cell: Option<(EntityHandle, BasicWordFields)>, } #[derive(Clone)] @@ -68,6 +73,15 @@ pub enum DictionaryMessage { ChangeDirection, TrySave(EntityHandle), GotoImport, + SelectField(EntityHandle, BasicWordFields), + EditField(String), +} + +#[derive(Clone, PartialEq, Eq, Hash, Copy)] +pub enum BasicWordFields { + Key, + Value, + Tags, } impl NavigatedPage for DictionaryState { @@ -262,10 +276,37 @@ impl NavigatedPage for DictionaryState { if reload_query { self.displayed_words_query.load(&dictionary); } + info!("Saved word"); return Task::none(); } } GotoImport => {} + SelectField(e, f) => { + info!("Editing word {}", e.id); + self.selected_cell = Some((e, f)); + 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 { + BasicWordFields::Key => { + word.key = value; + } + BasicWordFields::Value => { + word.value = value; + } + BasicWordFields::Tags => { + word.key = value; + } + } + } + + return self.launch_auto_save_offset(selected.0); + } + } } Task::none() @@ -277,6 +318,29 @@ impl NavigatedPage for DictionaryState { iced::widget::column![ self.groups_panel(), row![horizontal().width(8), self.words_list(),], + { + 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 { + BasicWordFields::Key => { + display = word.key.clone(); + } + BasicWordFields::Value => { + display = word.value.clone(); + } + BasicWordFields::Tags => { + display = word.tags.clone(); + } + } + } + text_input("", display.as_str()) + .width(Fill) + .id(iced::widget::Id::new("editor")) + .on_input(EditField) + }, row![ self.add_word_button(), horizontal().width(Fill), @@ -310,6 +374,8 @@ impl DictionaryState { auto_save_queue: HashMap::new(), displayed_words_query: words_query, groups_query, + selected_cell: None, + hovered_cell: None, }; result.init(); @@ -336,11 +402,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 +434,29 @@ impl DictionaryState { continue; } + let mut selection = None; + let mut hovered = None; + + if let Some(cell) = self.selected_cell.as_ref() { + if cell.0 == *word_handle { + selection = Some(cell.1); + } + } + + if let Some(cell) = self.hovered_cell.as_ref() { + if cell.0 == *word_handle { + hovered = 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, + hover: hovered, }; let lazy_line = lazy(word_line_data, Self::word_line); @@ -395,39 +478,45 @@ 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.into(); + } default_style - }), + }) + }; + + line = line.push( + hard_mouse_area(field( + "Слово", + &data.key, + data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Key, + )) + .on_press(SelectField(data.handle, BasicWordFields::Key)) ); 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() == BasicWordFields::Value, + )) + .on_press(SelectField(data.handle, BasicWordFields::Value)) ); 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() == BasicWordFields::Tags, + )) + .on_press(SelectField(data.handle, BasicWordFields::Tags)) ); let line_button = || { @@ -640,4 +729,6 @@ struct WordLineState { value: Box, tags: Box, handle: EntityHandle, + selection: Option, + hover: Option, } 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/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/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; From 8fe4f3b8f4572068ee83ea90bb0f64a4271fda36 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 00:41:36 +0300 Subject: [PATCH 02/13] New selected field movement --- src/data/repositories.rs | 4 ++ src/navigation.rs | 1 + src/pages/dictionary.rs | 134 +++++++++++++++++++++++++++++++++------ 3 files changed, 120 insertions(+), 19 deletions(-) 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/navigation.rs b/src/navigation.rs index be23c81..6db1a31 100644 --- a/src/navigation.rs +++ b/src/navigation.rs @@ -244,6 +244,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 1e96357..eec6217 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -6,7 +6,7 @@ use crate::data_provider::word_groups::{WordGroupDataLayer, WordGroupRepositoryI 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::{KeyPressedPage, NavigatedPage, Page, RootMessage}; use crate::pages::dictionary::DictionaryMessage::*; use crate::pages::dictionary_quiz::DictionaryQuizState; use crate::pages::import::ImportState; @@ -27,11 +27,17 @@ use iced::widget::text_input::default; use iced::widget::*; use iced::{Border, Color, Shadow, Task}; use iced_core::Length::Fill; +use iced_core::keyboard; +use iced_core::keyboard::Event; +use iced_core::keyboard::Key::Named; +use iced_core::keyboard::key::Physical::Code; use log::info; use rand::random_range; use std::ops::Add; use std::sync::Arc; use std::time::{Duration, Instant}; +use crate::pages::dictionary::BasicWordFields::*; +use crate::pages::dictionary::SelectionChangeDirection::*; pub struct DictionaryState { state: AppState, @@ -46,8 +52,7 @@ pub struct DictionaryState { auto_save_queue: HashMap, DateTime>, displayed_words_query: Query, groups_query: Query, - selected_cell: Option<(EntityHandle, BasicWordFields)>, - hovered_cell: Option<(EntityHandle, BasicWordFields)>, + selected_cell: Option<(EntityHandle, BasicWordFields, usize)>, } #[derive(Clone)] @@ -73,8 +78,17 @@ pub enum DictionaryMessage { ChangeDirection, TrySave(EntityHandle), GotoImport, - SelectField(EntityHandle, BasicWordFields), + SelectField(EntityHandle, BasicWordFields, usize), EditField(String), + ChangeSelectedField(SelectionChangeDirection), +} + +#[derive(Clone, PartialEq, Eq, Hash, Copy)] +pub enum SelectionChangeDirection { + Next, + Previous, + Forward, + Backward, } #[derive(Clone, PartialEq, Eq, Hash, Copy)] @@ -84,6 +98,8 @@ pub enum BasicWordFields { Tags, } +const FIELDS: &[BasicWordFields] = &[Key, Value, Tags]; + impl NavigatedPage for DictionaryState { fn navigate(&mut self, message: &DictionaryMessage) -> Option { if let Back = message { @@ -281,9 +297,9 @@ impl NavigatedPage for DictionaryState { } } GotoImport => {} - SelectField(e, f) => { + SelectField(e, f, index) => { info!("Editing word {}", e.id); - self.selected_cell = Some((e, f)); + self.selected_cell = Some((e, f, index)); return focus(iced::widget::Id::new("editor")); } EditField(value) => { @@ -307,6 +323,51 @@ impl NavigatedPage for DictionaryState { return self.launch_auto_save_offset(selected.0); } } + ChangeSelectedField(change) => { + return if let Some(selected) = self.selected_cell.clone() { + 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() + } + } } Task::none() @@ -375,7 +436,6 @@ impl DictionaryState { displayed_words_query: words_query, groups_query, selected_cell: None, - hovered_cell: None, }; result.init(); @@ -435,7 +495,6 @@ impl DictionaryState { } let mut selection = None; - let mut hovered = None; if let Some(cell) = self.selected_cell.as_ref() { if cell.0 == *word_handle { @@ -443,12 +502,6 @@ impl DictionaryState { } } - if let Some(cell) = self.hovered_cell.as_ref() { - if cell.0 == *word_handle { - hovered = Some(cell.1); - } - } - let word_line_data = WordLineState { is_included: self.included.contains(word_handle), key: word.key.clone().into(), @@ -456,7 +509,7 @@ impl DictionaryState { tags: word.tags.clone().into(), handle: *word_handle, selection, - hover: hovered, + number: i, }; let lazy_line = lazy(word_line_data, Self::word_line); @@ -499,7 +552,7 @@ impl DictionaryState { &data.key, data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Key, )) - .on_press(SelectField(data.handle, BasicWordFields::Key)) + .on_press(SelectField(data.handle, BasicWordFields::Key, data.number)), ); line = line.push( hard_mouse_area(field( @@ -507,7 +560,11 @@ impl DictionaryState { &data.value, data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Value, )) - .on_press(SelectField(data.handle, BasicWordFields::Value)) + .on_press(SelectField( + data.handle, + BasicWordFields::Value, + data.number, + )), ); line = line.push( @@ -516,7 +573,7 @@ impl DictionaryState { &data.tags, data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Tags, )) - .on_press(SelectField(data.handle, BasicWordFields::Tags)) + .on_press(SelectField(data.handle, BasicWordFields::Tags, data.number)), ); let line_button = || { @@ -715,6 +772,45 @@ 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: _, + text: _, + repeat: _, + } = message + && let Code(code) = pk + { + return match code { + keyboard::key::Code::ArrowDown => Task::done(RootMessage::Dictionary( + ChangeSelectedField(SelectionChangeDirection::Next), + )), + keyboard::key::Code::ArrowUp => Task::done(RootMessage::Dictionary( + ChangeSelectedField(SelectionChangeDirection::Previous), + )), + keyboard::key::Code::Tab => { + if let Named(keyboard::key::Named::Alt) = modifier { + Task::done(RootMessage::Dictionary(ChangeSelectedField( + SelectionChangeDirection::Backward, + ))) + } else { + Task::done(RootMessage::Dictionary(ChangeSelectedField( + SelectionChangeDirection::Forward, + ))) + } + } + _ => Task::none(), + }; + } + + Task::none() + } +} + pub fn split_with_coma(ts: &str) -> Vec { ts.split(',') .map(|ts| ts.to_lowercase().trim().to_string()) @@ -730,5 +826,5 @@ struct WordLineState { tags: Box, handle: EntityHandle, selection: Option, - hover: Option, + number: usize, } From adb6388ee2acbace81b8add3cebb10eab6482096 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 11:47:04 +0300 Subject: [PATCH 03/13] Prepare for extended word edit --- src/navigation.rs | 6 -- src/pages/dictionary.rs | 219 ++++++++++++++++++++++------------------ src/pages/word.rs | 23 ++--- 3 files changed, 126 insertions(+), 122 deletions(-) diff --git a/src/navigation.rs b/src/navigation.rs index 6db1a31..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, diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index eec6217..e096178 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -1,16 +1,18 @@ 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::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; @@ -22,22 +24,20 @@ 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::Status::Active; 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::Event; -use iced_core::keyboard::Key::Named; 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; use std::sync::Arc; use std::time::{Duration, Instant}; -use crate::pages::dictionary::BasicWordFields::*; -use crate::pages::dictionary::SelectionChangeDirection::*; pub struct DictionaryState { state: AppState, @@ -53,6 +53,7 @@ pub struct DictionaryState { displayed_words_query: Query, groups_query: Query, selected_cell: Option<(EntityHandle, BasicWordFields, usize)>, + word_editor: Option, } #[derive(Clone)] @@ -61,7 +62,8 @@ pub enum DictionaryMessage { SetTags(EntityHandle, String), SetKey(EntityHandle, String), SetValue(EntityHandle, String), - WordAction(EntityHandle), + ExtendedEdit(EntityHandle), + DeleteWord(EntityHandle), NewWord, Include(EntityHandle, bool), IncludeTag(String, bool), @@ -81,6 +83,7 @@ pub enum DictionaryMessage { SelectField(EntityHandle, BasicWordFields, usize), EditField(String), ChangeSelectedField(SelectionChangeDirection), + EditorAction(WordMessage), } #[derive(Clone, PartialEq, Eq, Hash, Copy)] @@ -122,11 +125,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()))); } @@ -186,14 +185,8 @@ impl NavigatedPage for DictionaryState { 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); + ExtendedEdit(i) => { + self.word_editor = Some(WordEditor::new(i, self.state.clone())); } Include(i, b) => { if b { @@ -308,13 +301,13 @@ impl NavigatedPage for DictionaryState { let mut dictionary = self.state.dictionary.write(); let word = dictionary.get_mut_static(selected.0).unwrap(); match selected.1 { - BasicWordFields::Key => { + Key => { word.key = value; } - BasicWordFields::Value => { + Value => { word.value = value; } - BasicWordFields::Tags => { + Tags => { word.key = value; } } @@ -324,14 +317,22 @@ impl NavigatedPage for DictionaryState { } } ChangeSelectedField(change) => { - return if let Some(selected) = self.selected_cell.clone() { + 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) }; + 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); + return SelectField( + self.displayed_words_query.get(new_index).unwrap(), + selected.1, + new_index, + ); } SelectField(selected.0, selected.1, selected.2) @@ -340,7 +341,8 @@ impl NavigatedPage for DictionaryState { 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; + let mut index = + FIELDS.iter().position(|&f| f == current).unwrap() as i8 + delta; if index < 0 { index = (FIELDS.len() - 1) as i8; } @@ -351,22 +353,26 @@ impl NavigatedPage for DictionaryState { }; Task::done(RootMessage::Dictionary(match change { - Next => { - update_vertical(true) - } - Previous => { - update_vertical(false) - } - Forward => { - update_horizontal(true) - } - Backward => { - update_horizontal(false) - } + 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); + } + EditorAction(action) => { + self.word_editor.as_mut().unwrap().update(action); } } @@ -379,37 +385,15 @@ impl NavigatedPage for DictionaryState { iced::widget::column![ self.groups_panel(), row![horizontal().width(8), self.words_list(),], - { - 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 { - BasicWordFields::Key => { - display = word.key.clone(); - } - BasicWordFields::Value => { - display = word.value.clone(); - } - BasicWordFields::Tags => { - display = word.tags.clone(); - } - } - } - text_input("", display.as_str()) - .width(Fill) - .id(iced::widget::Id::new("editor")) - .on_input(EditField) - }, + 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(), @@ -436,6 +420,7 @@ impl DictionaryState { displayed_words_query: words_query, groups_query, selected_cell: None, + word_editor: None, }; result.init(); @@ -496,10 +481,10 @@ impl DictionaryState { let mut selection = None; - if let Some(cell) = self.selected_cell.as_ref() { - if cell.0 == *word_handle { - selection = Some(cell.1); - } + if let Some(cell) = self.selected_cell.as_ref() + && cell.0 == *word_handle + { + selection = Some(cell.1); } let word_line_data = WordLineState { @@ -540,7 +525,7 @@ impl DictionaryState { 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.into(); + default_style.border.width = 3.0_f32; } default_style }) @@ -550,36 +535,32 @@ impl DictionaryState { hard_mouse_area(field( "Слово", &data.key, - data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Key, + data.selection.is_some() && data.selection.unwrap() == Key, )) - .on_press(SelectField(data.handle, BasicWordFields::Key, data.number)), + .on_press(SelectField(data.handle, Key, data.number)), ); line = line.push( hard_mouse_area(field( "Перевод", &data.value, - data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Value, + data.selection.is_some() && data.selection.unwrap() == Value, )) - .on_press(SelectField( - data.handle, - BasicWordFields::Value, - data.number, - )), + .on_press(SelectField(data.handle, Value, data.number)), ); line = line.push( hard_mouse_area(field( "Теги", &data.tags, - data.selection.is_some() && data.selection.unwrap() == BasicWordFields::Tags, + data.selection.is_some() && data.selection.unwrap() == Tags, )) - .on_press(SelectField(data.handle, BasicWordFields::Tags, data.number)), + .on_press(SelectField(data.handle, Tags, data.number)), ); let line_button = || { if !data.handle.real() { return button("-") - .on_press(WordAction(data.handle)) + .on_press(ExtendedEdit(data.handle)) .style(|_x: &Theme, _status: button::Status| Style { background: None, text_color: Color::BLACK, @@ -591,13 +572,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(); @@ -619,7 +608,7 @@ impl DictionaryState { .on_press(GotoTest) .width(Fill), ] - .width(250) + .width(FillPortion(1)) .spacing(DEFAULT_SPACING) .into() } @@ -719,6 +708,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)); @@ -779,30 +793,37 @@ impl KeyPressedPage for DictionaryState { modified_key: modifier, physical_key: pk, location: _, - modifiers: _, + modifiers: mods, text: _, repeat: _, } = message && let Code(code) = pk { return match code { - keyboard::key::Code::ArrowDown => Task::done(RootMessage::Dictionary( - ChangeSelectedField(SelectionChangeDirection::Next), - )), - keyboard::key::Code::ArrowUp => Task::done(RootMessage::Dictionary( - ChangeSelectedField(SelectionChangeDirection::Previous), - )), + keyboard::key::Code::ArrowDown => { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Next))) + } + keyboard::key::Code::ArrowUp => { + Task::done(RootMessage::Dictionary(ChangeSelectedField(Previous))) + } keyboard::key::Code::Tab => { - if let Named(keyboard::key::Named::Alt) = modifier { - Task::done(RootMessage::Dictionary(ChangeSelectedField( - SelectionChangeDirection::Backward, - ))) + 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( - SelectionChangeDirection::Forward, - ))) + 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(), }; } diff --git a/src/pages/word.rs b/src/pages/word.rs index 6232f66..239ec1a 100644 --- a/src/pages/word.rs +++ b/src/pages/word.rs @@ -8,22 +8,13 @@ use iced::widget::button::danger; use iced::widget::{button, column, row, space, text, text_input}; use iced::{Element, Fill, Task}; -pub struct WordState { +pub struct WordEditor { state: AppState, handle: EntityHandle, } -impl NavigatedPage for WordState { - fn navigate(&mut self, message: &WordMessage) -> Option { - if let Back = message { - Some(PreviousPage) - } else { - None - } - } - - fn navigated(&mut self) {} - fn update(&mut self, message: WordMessage) -> Task { +impl WordEditor { + pub(crate) fn update(&mut self, message: WordMessage) { // match message { // Back => {} // Save => { @@ -60,10 +51,9 @@ impl NavigatedPage for WordState { // self.word.additional.remove(key.as_str()); // } // } - Task::none() } - fn view(&self) -> Element<'_, WordMessage> { + pub(crate) fn view(&self) -> Element<'_, WordMessage> { // let mut fast_add = row![]; // if !self.word.additional.contains_key("reading") { // fast_add = fast_add.push( @@ -129,13 +119,13 @@ impl NavigatedPage for WordState { } } -impl WordState { +impl WordEditor { pub fn new(handle: EntityHandle, state: AppState) -> Self { Self { state, handle } } } -impl WordState { +impl WordEditor { fn get_view_for_more(&self, value: (&String, &String)) -> Element<'_, WordMessage> { match value.0.as_str() { "reading" => self.reading_field(value.1), @@ -180,7 +170,6 @@ impl WordState { #[derive(Debug, Clone)] pub enum WordMessage { Save, - Back, Delete, SetTags(String), SetKey(String), From d59776d7131458caf3d69bf764cff5cdc798e5eb Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 12:03:13 +0300 Subject: [PATCH 04/13] More keyboard handling for dictionary --- src/pages/dictionary.rs | 4 ++ src/pages/word.rs | 127 ++++++++++++++++++++-------------------- 2 files changed, 68 insertions(+), 63 deletions(-) diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index e096178..55c09c2 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -292,7 +292,11 @@ impl NavigatedPage for DictionaryState { 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(WordEditor::new(e, self.state.clone())); + } self.selected_cell = Some((e, f, index)); + return focus(iced::widget::Id::new("editor")); } EditField(value) => { diff --git a/src/pages/word.rs b/src/pages/word.rs index 239ec1a..0e4920e 100644 --- a/src/pages/word.rs +++ b/src/pages/word.rs @@ -5,8 +5,10 @@ use crate::navigation::{NavigatedPage, Page, RootMessage}; 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::widget::{button, column, row, rule, scrollable, space, text, text_input}; use iced::{Element, Fill, Task}; +use crate::data::repositories::Repository; +use crate::jl_button; pub struct WordEditor { state: AppState, @@ -54,68 +56,67 @@ impl WordEditor { } pub(crate) 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 fast_add = row![]; + let word = self.state.dictionary.read().get_static(self.handle).unwrap(); + if !word.additional.contains_key("reading") { + fast_add = fast_add.push( + button("Чтение") + .style(button::text) + .on_press(AddAdditional("reading".to_string())), + ); + } + if !word.additional.contains_key("description") { + fast_add = fast_add.push( + button("Описание") + .style(button::text) + .on_press(AddAdditional("description".to_string())), + ); + } + + if !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", &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), + ]; + + 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![ + jl_button!("Сохранить").on_press(Save), + button("Удалить").style(danger).on_press(Delete), + ] + .spacing(DEFAULT_SPACING) + ] + .spacing(DEFAULT_SPACING).into() + + + } } From 5d305affd34340333dac7f6966dcb60d73e7930c Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 12:38:21 +0300 Subject: [PATCH 05/13] Fixing some bugs --- src/pages/dictionary.rs | 37 +++++++++++++--- src/pages/word.rs | 96 ++++++++++++++++++++--------------------- 2 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index 55c09c2..3ad5d09 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -53,7 +53,7 @@ pub struct DictionaryState { displayed_words_query: Query, groups_query: Query, selected_cell: Option<(EntityHandle, BasicWordFields, usize)>, - word_editor: Option, + word_editor: Option>, } #[derive(Clone)] @@ -84,6 +84,7 @@ pub enum DictionaryMessage { EditField(String), ChangeSelectedField(SelectionChangeDirection), EditorAction(WordMessage), + ExtendedWordEdited } #[derive(Clone, PartialEq, Eq, Hash, Copy)] @@ -186,7 +187,7 @@ impl NavigatedPage for DictionaryState { } ExtendedEdit(i) => { - self.word_editor = Some(WordEditor::new(i, self.state.clone())); + self.word_editor = Some(self.editor_for_word(i)); } Include(i, b) => { if b { @@ -271,6 +272,7 @@ impl NavigatedPage for DictionaryState { 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(); @@ -284,6 +286,10 @@ 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)); + self.word_editor = Some(self.editor_for_word(selected.0)); + } } info!("Saved word"); return Task::none(); @@ -293,7 +299,7 @@ impl NavigatedPage for DictionaryState { 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(WordEditor::new(e, self.state.clone())); + self.word_editor = Some(self.editor_for_word(e)); } self.selected_cell = Some((e, f, index)); @@ -374,9 +380,26 @@ impl NavigatedPage for DictionaryState { } self.displayed_words_query.load(&dictionary); + if let Some(selected) = self.selected_cell && selected.0 != i { + self.selected_cell = None; + self.word_editor = None; + } } EditorAction(action) => { - self.word_editor.as_mut().unwrap().update(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); } } @@ -433,6 +456,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 @@ -564,7 +591,7 @@ impl DictionaryState { let line_button = || { if !data.handle.real() { return button("-") - .on_press(ExtendedEdit(data.handle)) + .on_press(DeleteWord(data.handle)) .style(|_x: &Theme, _status: button::Status| Style { background: None, text_color: Color::BLACK, diff --git a/src/pages/word.rs b/src/pages/word.rs index 0e4920e..0f7c699 100644 --- a/src/pages/word.rs +++ b/src/pages/word.rs @@ -1,58 +1,46 @@ use crate::app::AppState; use crate::data::entities::{EntityHandle, WordData}; -use crate::navigation::Page::PreviousPage; -use crate::navigation::{NavigatedPage, Page, RootMessage}; +use crate::data::repositories::Repository; use crate::pages::word::WordMessage::*; use crate::styling::*; use iced::widget::button::danger; -use iced::widget::{button, column, row, rule, scrollable, space, text, text_input}; -use iced::{Element, Fill, Task}; -use crate::data::repositories::Repository; -use crate::jl_button; +use iced::widget::space::horizontal; +use iced::widget::{button, column, row, rule, scrollable, text, text_input}; +use iced::{Element, Fill}; -pub struct WordEditor { +pub struct WordEditor { state: AppState, handle: EntityHandle, + on_delete: Option, + on_edit: Option, } -impl WordEditor { - pub(crate) fn update(&mut self, message: WordMessage) { - // 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()); - // } - // } +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.as_str()); + } + } + self.on_edit.take() } pub(crate) fn view(&self) -> Element<'_, WordMessage> { @@ -108,7 +96,7 @@ impl WordEditor { column![ col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill), row![ - jl_button!("Сохранить").on_press(Save), + horizontal().width(Fill), button("Удалить").style(danger).on_press(Delete), ] .spacing(DEFAULT_SPACING) @@ -120,13 +108,22 @@ impl WordEditor { } } -impl WordEditor { +impl WordEditor { pub fn new(handle: EntityHandle, state: AppState) -> Self { - Self { state, handle } + Self { state, handle, on_delete: None, on_edit: None } + } + + 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 { +impl WordEditor { fn get_view_for_more(&self, value: (&String, &String)) -> Element<'_, WordMessage> { match value.0.as_str() { "reading" => self.reading_field(value.1), @@ -170,7 +167,6 @@ impl WordEditor { #[derive(Debug, Clone)] pub enum WordMessage { - Save, Delete, SetTags(String), SetKey(String), From 05c82bee0276ddc775ba22c50a4b94bd95b61912 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 12:48:04 +0300 Subject: [PATCH 06/13] Fix tag edit bug --- src/pages/dictionary.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index 3ad5d09..1351a10 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -318,7 +318,7 @@ impl NavigatedPage for DictionaryState { word.value = value; } Tags => { - word.key = value; + word.tags = value; } } } @@ -873,9 +873,9 @@ 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, From 2568b9f292c09961c88e175709525ee819056e19 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 13:09:20 +0300 Subject: [PATCH 07/13] More fixes for dictionary panel --- src/pages/dictionary.rs | 49 +++++++---------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index 1351a10..c6b3b26 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -59,9 +59,6 @@ pub struct DictionaryState { #[derive(Clone)] pub enum DictionaryMessage { Back, - SetTags(EntityHandle, String), - SetKey(EntityHandle, String), - SetValue(EntityHandle, String), ExtendedEdit(EntityHandle), DeleteWord(EntityHandle), NewWord, @@ -151,43 +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; - } - - self.update_tags(); - return self.launch_auto_save_offset(i); - } - 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)) + } } Include(i, b) => { if b { @@ -278,6 +243,7 @@ impl NavigatedPage for DictionaryState { 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(); @@ -288,7 +254,9 @@ impl NavigatedPage for DictionaryState { 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)); - self.word_editor = Some(self.editor_for_word(selected.0)); + if self.word_editor.is_some() { + self.word_editor = Some(self.editor_for_word(handle)); + } } } info!("Saved word"); @@ -380,10 +348,9 @@ impl NavigatedPage for DictionaryState { } self.displayed_words_query.load(&dictionary); - if let Some(selected) = self.selected_cell && selected.0 != i { self.selected_cell = None; self.word_editor = None; - } + } EditorAction(action) => { let result = self.word_editor.as_mut().unwrap().update(action); From be64b1da2d6a5cdac18843511dbbaee8aa26c0b9 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 13:12:45 +0300 Subject: [PATCH 08/13] Fix group delete --- src/pages/dictionary.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/dictionary.rs b/src/pages/dictionary.rs index c6b3b26..e8b54e9 100644 --- a/src/pages/dictionary.rs +++ b/src/pages/dictionary.rs @@ -230,7 +230,8 @@ impl NavigatedPage for DictionaryState { } self.groups_query.load(&groups); } - + self.selected_cell = None; + self.word_editor = None; self.update_tags() } ChangeDirection => { From f61f93717ceeccfbff36fde960184ea4f68aa2c4 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 13:28:23 +0300 Subject: [PATCH 09/13] Custom fields repo into AppState --- sql/custom_fields.sql | 2 +- src/app.rs | 4 ++++ src/data/entities.rs | 11 +++++++++++ src/data_provider/custom_fields.rs | 1 + src/data_provider/history.rs | 4 ++-- src/data_provider/sqlite.rs | 1 + 6 files changed, 20 insertions(+), 3 deletions(-) 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/data/entities.rs b/src/data/entities.rs index dcedb50..b16b6fb 100644 --- a/src/data/entities.rs +++ b/src/data/entities.rs @@ -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, 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/sqlite.rs b/src/data_provider/sqlite.rs index 617033f..d3e7262 100644 --- a/src/data_provider/sqlite.rs +++ b/src/data_provider/sqlite.rs @@ -64,6 +64,7 @@ fn make_history(conn: &Connection) -> 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(()) } From cbf49412eca782b34632d8b94017b7b4f889892d Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 13:56:26 +0300 Subject: [PATCH 10/13] additional fields encoding and decoding --- src/data/entities.rs | 4 ++-- src/data_provider/words.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/data/entities.rs b/src/data/entities.rs index b16b6fb..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 { diff --git a/src/data_provider/words.rs b/src/data_provider/words.rs index c82e85a..b1f25a8 100644 --- a/src/data_provider/words.rs +++ b/src/data_provider/words.rs @@ -1,7 +1,9 @@ +use litemap::LiteMap; use crate::data::entities::WordData; use crate::data::repositories::{DataRepository, DataRepositoryInner}; use crate::data_provider::{SqliteDbLayer, TableAccessQuerySet}; use rusqlite::{Error, Params, Row}; +use crate::data::{Id, SmallId}; pub type WordRepository = DataRepository; pub type WordRepositoryInner = DataRepositoryInner; @@ -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,29 @@ 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, ) } } + +fn additional_to_string(map: &LiteMap) -> String { + let mut parts = vec![]; + for (id, value) in map { + parts.push(id.to_string()); + parts.push(value.clone()); + } + parts.join("") +} + +fn additional_from_string(data: String) -> LiteMap { + 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 +} From e5622c70dec6cb09296f21f4274f10680d7ff5a9 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 14:02:57 +0300 Subject: [PATCH 11/13] Fix deck filter --- src/core/deck.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From 0de809fcce927a1211029fa5968821bd99d2d4b4 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 22:08:50 +0300 Subject: [PATCH 12/13] Compressing word fields --- src/data_provider/words.rs | 19 +++++- src/pages/import.rs | 46 +++++++++++-- src/pages/repetition.rs | 6 +- src/pages/word.rs | 131 +++++++++++++++++-------------------- 4 files changed, 121 insertions(+), 81 deletions(-) diff --git a/src/data_provider/words.rs b/src/data_provider/words.rs index b1f25a8..75f049f 100644 --- a/src/data_provider/words.rs +++ b/src/data_provider/words.rs @@ -1,9 +1,9 @@ -use litemap::LiteMap; 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}; -use crate::data::{Id, SmallId}; pub type WordRepository = DataRepository; pub type WordRepositoryInner = DataRepositoryInner; @@ -61,7 +61,7 @@ impl TableAccessQuerySet for WordQuerySet { } fn additional_to_string(map: &LiteMap) -> String { - let mut parts = vec![]; + let mut parts = Vec::with_capacity(map.len() * 2); for (id, value) in map { parts.push(id.to_string()); parts.push(value.clone()); @@ -69,7 +69,20 @@ fn additional_to_string(map: &LiteMap) -> String { 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()); diff --git a/src/pages/import.rs b/src/pages/import.rs index 6633340..7284b2c 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::{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,6 +26,7 @@ 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; @@ -428,7 +430,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 +453,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 +487,7 @@ impl ImportState { word.tags = collected_string; } &_ => { - word.additional.insert((*dest).clone(), collected_string); + word.additional.insert(fields[dest], collected_string); } } } @@ -554,4 +567,29 @@ 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(()) + } } 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/word.rs b/src/pages/word.rs index 0f7c699..3793721 100644 --- a/src/pages/word.rs +++ b/src/pages/word.rs @@ -1,5 +1,6 @@ use crate::app::AppState; -use crate::data::entities::{EntityHandle, WordData}; +use crate::data::Id; +use crate::data::entities::{CustomField, EntityHandle, WordData}; use crate::data::repositories::Repository; use crate::pages::word::WordMessage::*; use crate::styling::*; @@ -7,15 +8,17 @@ use iced::widget::button::danger; 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 WordEditor { state: AppState, handle: EntityHandle, on_delete: Option, on_edit: Option, + fields: Vec, } -impl WordEditor { +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(); @@ -37,7 +40,7 @@ impl WordEditor { word.additional.insert(key, "".to_string()); } RemoveAdditional(key) => { - word.additional.remove(key.as_str()); + word.additional.remove(&key); } } self.on_edit.take() @@ -45,36 +48,25 @@ impl WordEditor { pub(crate) fn view(&self) -> Element<'_, WordMessage> { let mut fast_add = row![]; - let word = self.state.dictionary.read().get_static(self.handle).unwrap(); - if !word.additional.contains_key("reading") { - fast_add = fast_add.push( - button("Чтение") - .style(button::text) - .on_press(AddAdditional("reading".to_string())), - ); - } - if !word.additional.contains_key("description") { - fast_add = fast_add.push( - button("Описание") - .style(button::text) - .on_press(AddAdditional("description".to_string())), - ); - } - - if !word.additional.contains_key("context") { - fast_add = fast_add.push( - button("В контексте") - .style(button::text) - .on_press(AddAdditional("context".to_string())), - ); + 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)), + ); + } } let mut col = iced::widget::column![ - column![ - text!("Ключ"), - text_input("key", &word.key).on_input(SetKey), - ] - .spacing(QUARTER_SPACING), + column![text!("Ключ"), text_input("key", &word.key).on_input(SetKey),] + .spacing(QUARTER_SPACING), column![ text!("Значение"), text_input("value", &word.value).on_input(SetValue), @@ -86,33 +78,46 @@ impl WordEditor { ] .spacing(QUARTER_SPACING), rule::horizontal(2), - scrollable(fast_add), + 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) + column![ + col.spacing(DEFAULT_SPACING / 2.0).width(Fill).height(Fill), + row![ + horizontal().width(Fill), + button("Удалить").style(danger).on_press(Delete), ] - .spacing(DEFAULT_SPACING).into() - - - + .spacing(DEFAULT_SPACING) + ] + .spacing(DEFAULT_SPACING) + .into() } } impl WordEditor { pub fn new(handle: EntityHandle, state: AppState) -> Self { - Self { state, handle, on_delete: None, on_edit: None } + let fields = state + .custom_fields + .read() + .iter() + .cloned() + .collect::>(); + Self { + state, + handle, + on_delete: None, + on_edit: None, + fields, + } } - + pub fn on_delete(mut self, message: Message) -> Self { self.on_delete = Some(message); self @@ -124,38 +129,22 @@ impl WordEditor { } impl WordEditor { - 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()), + 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 reading_field(&self, value: &str) -> Element<'_, WordMessage> { - self.additional_field(value, "Чтение слова".to_string(), "reading".to_string()) - } - - 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), @@ -171,7 +160,7 @@ pub enum WordMessage { SetTags(String), SetKey(String), SetValue(String), - AddAdditional(String), - RemoveAdditional(String), - SetAdditional(String, String), + AddAdditional(Id), + RemoveAdditional(Id), + SetAdditional(Id, String), } From ccfb302770e47cb2e9feb8d20b0f9f2fcac3d021 Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Sun, 13 Sep 2026 12:44:51 +0300 Subject: [PATCH 13/13] Prepare for new anki format --- src/data_provider/import.rs | 18 ++++++++++++++++-- src/pages/import.rs | 27 ++++++++++++++++++--------- 2 files changed, 34 insertions(+), 11 deletions(-) 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/pages/import.rs b/src/pages/import.rs index 7284b2c..0de44c1 100644 --- a/src/pages/import.rs +++ b/src/pages/import.rs @@ -6,7 +6,7 @@ use crate::data::repositories::{BatchedRepository, Repository}; use crate::data_provider::import::{ImportData, ImportGroup, get_words_of_group, load_groups}; use crate::fs::*; 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; @@ -33,6 +33,7 @@ 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"]; @@ -362,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 @@ -376,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)?; @@ -387,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) { @@ -593,3 +598,7 @@ impl ImportState { Ok(()) } } + +pub enum AnkiSchema { + Old, Beta +} \ No newline at end of file