From 47bc83d4ae5d753e014283937baff85a51e103fd Mon Sep 17 00:00:00 2001 From: Mikhail Mitrofanov Date: Fri, 11 Sep 2026 00:01:19 +0300 Subject: [PATCH] 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;