From 719ad7586053ffff1f8b33c0a0550d18f0d28beb Mon Sep 17 00:00:00 2001 From: Kneemund Date: Fri, 1 Nov 2024 23:28:55 +0100 Subject: [PATCH] feat: language selector, lazy spellchecking --- crates/rnote-engine/src/document/mod.rs | 4 + crates/rnote-engine/src/engine/import.rs | 1 + crates/rnote-engine/src/engine/mod.rs | 50 ++++++- .../rnote-engine/src/pens/typewriter/mod.rs | 25 +++- .../src/pens/typewriter/penevents.rs | 17 ++- crates/rnote-engine/src/store/mod.rs | 2 + crates/rnote-engine/src/strokes/textstroke.rs | 141 +++++++++++------- crates/rnote-engine/src/widgetflags.rs | 4 + crates/rnote-ui/data/ui/settingspanel.ui | 10 ++ crates/rnote-ui/src/appwindow/mod.rs | 4 + crates/rnote-ui/src/settingspanel/mod.rs | 82 ++++++++++ 11 files changed, 270 insertions(+), 70 deletions(-) diff --git a/crates/rnote-engine/src/document/mod.rs b/crates/rnote-engine/src/document/mod.rs index 53ccee1a58..b5db8937bc 100644 --- a/crates/rnote-engine/src/document/mod.rs +++ b/crates/rnote-engine/src/document/mod.rs @@ -7,6 +7,7 @@ pub use background::Background; pub use format::Format; // Imports +use crate::engine::Spellchecker; use crate::{Camera, CloneConfig, StrokeStore, WidgetFlags}; use core::fmt::Display; use p2d::bounding_volume::{Aabb, BoundingVolume}; @@ -107,6 +108,8 @@ pub struct Document { pub layout: Layout, #[serde(rename = "snap_positions")] pub snap_positions: bool, + #[serde(rename = "spellcheck_language")] + pub spellcheck_language: Option, } impl Default for Document { @@ -120,6 +123,7 @@ impl Default for Document { background: Background::default(), layout: Layout::default(), snap_positions: false, + spellcheck_language: Spellchecker::default_language(), } } } diff --git a/crates/rnote-engine/src/engine/import.rs b/crates/rnote-engine/src/engine/import.rs index 8c0bc2cd87..9a6c03d531 100644 --- a/crates/rnote-engine/src/engine/import.rs +++ b/crates/rnote-engine/src/engine/import.rs @@ -181,6 +181,7 @@ impl Engine { widget_flags |= self.doc_resize_to_fit_content(); widget_flags.redraw = true; widget_flags.refresh_ui = true; + widget_flags.spellcheck_language_modified = true; widget_flags } diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index a3a2460b83..a9b2306784 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -38,18 +38,31 @@ use std::time::Instant; use tracing::error; pub struct Spellchecker { - pub broker: enchant::Broker, + broker: enchant::Broker, pub dict: Option, } +impl Spellchecker { + pub fn default_language() -> Option { + glib::language_names() + .get(0) + .map(|language| language.to_string()) + } + + pub fn available_languages() -> Vec { + enchant::Broker::new() + .list_dicts() + .iter() + .map(|dict| dict.lang.to_owned()) + .collect() + } +} + impl Default for Spellchecker { fn default() -> Self { - let mut enchant_broker = enchant::Broker::new(); - let enchant_dict = enchant_broker.request_dict(glib::language_names().first().unwrap()); - Self { - broker: enchant_broker, - dict: enchant_dict.ok(), + broker: enchant::Broker::new(), + dict: None, } } } @@ -329,6 +342,31 @@ impl Engine { } } + pub fn refresh_spellcheck_language(&mut self) { + self.spellchecker.dict = self + .document + .spellcheck_language + .as_ref() + .and_then(|language| { + self.spellchecker + .broker + .request_dict(language.as_str()) + .ok() + }); + + if let Pen::Typewriter(typewriter) = self.penholder.current_pen_ref() { + typewriter.refresh_spellcheck_cache_in_modifying_stroke(&mut EngineViewMut { + tasks_tx: self.tasks_tx.clone(), + pens_config: &mut self.pens_config, + document: &mut self.document, + store: &mut self.store, + camera: &mut self.camera, + audioplayer: &mut self.audioplayer, + spellchecker: &mut self.spellchecker, + }); + } + } + pub fn optimize_epd(&self) -> bool { self.optimize_epd } diff --git a/crates/rnote-engine/src/pens/typewriter/mod.rs b/crates/rnote-engine/src/pens/typewriter/mod.rs index 6d75b1dd3a..c378c62421 100644 --- a/crates/rnote-engine/src/pens/typewriter/mod.rs +++ b/crates/rnote-engine/src/pens/typewriter/mod.rs @@ -187,7 +187,7 @@ impl DrawableOnDoc for Typewriter { } // Draw error ranges - for (start_index, length) in &textstroke.error_words { + for (start_index, length) in &textstroke.spellcheck_result.errors { textstroke.text_style.draw_text_error( cx, textstroke.text.clone(), @@ -654,7 +654,10 @@ impl Typewriter { let text_len = text.len(); text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(text, pos, text_style, engine_view.spellchecker); + + let mut textstroke = TextStroke::new(text, pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellchecker); + let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -681,7 +684,10 @@ impl Typewriter { let text_len = text.len(); text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(text, *pos, text_style, engine_view.spellchecker); + + let mut textstroke = TextStroke::new(text, *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellchecker); + let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -808,6 +814,19 @@ impl Typewriter { widget_flags } + pub(crate) fn refresh_spellcheck_cache_in_modifying_stroke( + &self, + engine_view: &mut EngineViewMut, + ) { + if let TypewriterState::Modifying { stroke_key, .. } = self.state { + if let Some(Stroke::TextStroke(textstroke)) = + engine_view.store.get_stroke_mut(stroke_key) + { + textstroke.check_spelling_refresh_cache(engine_view.spellchecker); + } + } + } + pub(crate) fn toggle_text_attribute_current_selection( &mut self, text_attribute: TextAttribute, diff --git a/crates/rnote-engine/src/pens/typewriter/penevents.rs b/crates/rnote-engine/src/pens/typewriter/penevents.rs index 4cad65fd25..d4d426afc1 100644 --- a/crates/rnote-engine/src/pens/typewriter/penevents.rs +++ b/crates/rnote-engine/src/pens/typewriter/penevents.rs @@ -37,7 +37,7 @@ impl Typewriter { { // When clicked on a textstroke, we start modifying it if let Some(Stroke::TextStroke(textstroke)) = - engine_view.store.get_stroke_ref(stroke_key) + engine_view.store.get_stroke_mut(stroke_key) { let cursor = if let Ok(new_cursor) = // get the cursor for the current position @@ -48,6 +48,7 @@ impl Typewriter { GraphemeCursor::new(0, textstroke.text.len(), true) }; + textstroke.check_spelling_refresh_cache(&engine_view.spellchecker); engine_view.store.update_chrono_to_last(stroke_key); new_state = TypewriterState::Modifying { @@ -502,12 +503,9 @@ impl Typewriter { text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new( - String::from(keychar), - *pos, - text_style, - engine_view.spellchecker, - ); + let mut textstroke = + TextStroke::new(String::from(keychar), *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellchecker); let mut cursor = GraphemeCursor::new(0, textstroke.text.len(), true); @@ -1105,7 +1103,10 @@ impl Typewriter { text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); let text_len = text.len(); - let textstroke = TextStroke::new(text, *pos, text_style, engine_view.spellchecker); + + let mut textstroke = TextStroke::new(text, *pos, text_style); + textstroke.check_spelling_refresh_cache(engine_view.spellchecker); + let cursor = GraphemeCursor::new(text_len, text_len, true); let stroke_key = engine_view diff --git a/crates/rnote-engine/src/store/mod.rs b/crates/rnote-engine/src/store/mod.rs index 1e0931d7eb..631a793b7b 100644 --- a/crates/rnote-engine/src/store/mod.rs +++ b/crates/rnote-engine/src/store/mod.rs @@ -263,6 +263,7 @@ impl StrokeStore { widget_flags.hide_undo = Some(!self.can_undo()); widget_flags.hide_redo = Some(!self.can_redo()); widget_flags.store_modified = true; + widget_flags.spellcheck_language_modified = true; widget_flags } @@ -284,6 +285,7 @@ impl StrokeStore { widget_flags.hide_undo = Some(!self.can_undo()); widget_flags.hide_redo = Some(!self.can_redo()); widget_flags.store_modified = true; + widget_flags.spellcheck_language_modified = true; widget_flags } diff --git a/crates/rnote-engine/src/strokes/textstroke.rs b/crates/rnote-engine/src/strokes/textstroke.rs index cbebc8b68f..1d7d79846b 100644 --- a/crates/rnote-engine/src/strokes/textstroke.rs +++ b/crates/rnote-engine/src/strokes/textstroke.rs @@ -474,6 +474,12 @@ impl TextStyle { } } +#[derive(Debug, Clone, Default)] +pub struct SpellcheckResult { + pub language: Option, + pub errors: BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, rename = "textstroke")] pub struct TextStroke { @@ -487,7 +493,7 @@ pub struct TextStroke { #[serde(rename = "text_style")] pub text_style: TextStyle, #[serde(skip)] - pub error_words: BTreeMap, + pub spellcheck_result: SpellcheckResult, } impl Default for TextStroke { @@ -496,7 +502,7 @@ impl Default for TextStroke { text: String::default(), transform: Transform::default(), text_style: TextStyle::default(), - error_words: BTreeMap::new(), + spellcheck_result: SpellcheckResult::default(), } } } @@ -588,24 +594,13 @@ impl Drawable for TextStroke { } impl TextStroke { - pub fn new( - text: String, - upper_left_pos: na::Vector2, - text_style: TextStyle, - spellchecker: &Spellchecker, - ) -> Self { - let text_length = text.len(); - - let mut textstroke = Self { + pub fn new(text: String, upper_left_pos: na::Vector2, text_style: TextStyle) -> Self { + Self { text, transform: Transform::new_w_isometry(na::Isometry2::new(upper_left_pos, 0.0)), text_style, - error_words: BTreeMap::new(), - }; - - textstroke.check_spelling(0, text_length, spellchecker); - - textstroke + spellcheck_result: SpellcheckResult::default(), + } } pub fn get_text_slice_for_range(&self, range: Range) -> &str { @@ -639,7 +634,67 @@ impl TextStroke { )) } - pub fn check_spelling( + fn check_spelling_words(&mut self, words: Vec<(usize, String)>, dict: &enchant::Dict) { + for (word_start_index, word) in words { + if let Ok(valid_word) = dict.check(word.as_str()) { + let word_end_index = word_start_index + word.len(); + let word_range = word_start_index..word_end_index; + + self.spellcheck_result + .errors + .retain(|key, _| !word_range.contains(key)); + + // TODO: maybe faster for large texts + // let keys_to_remove = self + // .error_words + // .range(word_range) + // .map(|(&key, _)| key) + // .collect_vec(); + + // for existing_word in keys_to_remove { + // self.error_words.remove(&existing_word); + // } + + if !valid_word { + self.spellcheck_result + .errors + .insert(word_start_index, word.len()); + } + } else { + error!("Failed to check spelling for word '{word}'"); + } + } + } + + pub fn check_spelling_refresh_cache(&mut self, spellchecker: &Spellchecker) { + if let Some(dict) = &spellchecker.dict { + let language = dict.get_lang(); + + let language_changed = self + .spellcheck_result + .language + .clone() + .is_none_or(|cached_language| cached_language != language); + + if language_changed { + self.spellcheck_result.errors.clear(); + self.spellcheck_result.language = Some(language.to_owned()); + + let words = self + .text + .unicode_word_indices() + .map(|(index, word)| (index, word.to_owned())) + .collect_vec(); + + self.check_spelling_words(words, dict); + } + } else { + self.spellcheck_result.errors.clear(); + self.spellcheck_result.language = None; + } + } + + pub fn check_spelling_range( &mut self, start_index: usize, end_index: usize, @@ -647,32 +702,7 @@ impl TextStroke { ) { if let Some(dict) = &spellchecker.dict { let words = self.get_surrounding_words(start_index, end_index); - - for (word_start_index, word) in words { - if let Ok(valid_word) = dict.check(word.as_str()) { - let word_end_index = word_start_index + word.len(); - let word_range = word_start_index..word_end_index; - - self.error_words.retain(|key, _| !word_range.contains(key)); - - // TODO: maybe faster for large texts - // let keys_to_remove = self - // .error_words - // .range(word_range) - // .map(|(&key, _)| key) - // .collect_vec(); - - // for existing_word in keys_to_remove { - // self.error_words.remove(&existing_word); - // } - - if !valid_word { - self.error_words.insert(word_start_index, word.len()); - } - } else { - error!("Failed to check spelling for word '{word}'"); - } - } + self.check_spelling_words(words, dict); } } @@ -690,7 +720,7 @@ impl TextStroke { // translate the text attributes self.translate_attrs_after_cursor(cur_pos, text.len() as i32); - self.check_spelling(cur_pos, next_pos, spellchecker); + self.check_spelling_range(cur_pos, next_pos, spellchecker); *cursor = GraphemeCursor::new(next_pos, self.text.len(), true); } @@ -712,7 +742,7 @@ impl TextStroke { prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); - self.check_spelling(prev_pos, cur_pos, spellchecker); + self.check_spelling_range(prev_pos, cur_pos, spellchecker); } // New text length, new cursor @@ -737,7 +767,7 @@ impl TextStroke { -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); - self.check_spelling(cur_pos, next_pos, spellchecker); + self.check_spelling_range(cur_pos, next_pos, spellchecker); } // New text length, new cursor @@ -762,7 +792,7 @@ impl TextStroke { prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); - self.check_spelling(prev_pos, cur_pos, spellchecker); + self.check_spelling_range(prev_pos, cur_pos, spellchecker); // New text length, new cursor *cursor = GraphemeCursor::new(prev_pos, self.text.len(), true); @@ -786,7 +816,7 @@ impl TextStroke { -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); - self.check_spelling(cur_pos, next_pos, spellchecker); + self.check_spelling_range(cur_pos, next_pos, spellchecker); // New text length, new cursor *cursor = GraphemeCursor::new(cur_pos, self.text.len(), true); @@ -827,7 +857,7 @@ impl TextStroke { -(cursor_range.end as i32 - cursor_range.start as i32) + replace_text.len() as i32, ); - self.check_spelling( + self.check_spelling_range( cursor_range.start, cursor_range.start + replace_text.len(), spellchecker, @@ -840,16 +870,21 @@ impl TextStroke { fn translate_attrs_after_cursor(&mut self, from_pos: usize, offset: i32) { let translated_words = if offset < 0 { let to_pos = from_pos.saturating_add_signed(offset as isize); - self.error_words.split_off(&to_pos).split_off(&from_pos) + self.spellcheck_result + .errors + .split_off(&to_pos) + .split_off(&from_pos) } else { - self.error_words.split_off(&from_pos) + self.spellcheck_result.errors.split_off(&from_pos) }; for (word_start, word_length) in translated_words { let new_word_start = word_start.saturating_add_signed(offset as isize); if new_word_start >= from_pos { - self.error_words.insert(new_word_start, word_length); + self.spellcheck_result + .errors + .insert(new_word_start, word_length); } } diff --git a/crates/rnote-engine/src/widgetflags.rs b/crates/rnote-engine/src/widgetflags.rs index d348063a9c..815173faab 100644 --- a/crates/rnote-engine/src/widgetflags.rs +++ b/crates/rnote-engine/src/widgetflags.rs @@ -10,6 +10,8 @@ pub struct WidgetFlags { pub refresh_ui: bool, /// Indicates that the store was modified, i.e. new strokes inserted, modified, etc. . pub store_modified: bool, + /// Indicates that the spellcheck language was modified. + pub spellcheck_language_modified: bool, /// Update the current view offsets and size. pub view_modified: bool, /// Indicates that the camera has changed it's temporary zoom. @@ -35,6 +37,7 @@ impl Default for WidgetFlags { resize: false, refresh_ui: false, store_modified: false, + spellcheck_language_modified: false, view_modified: false, zoomed_temporarily: false, zoomed: false, @@ -61,6 +64,7 @@ impl std::ops::BitOrAssign for WidgetFlags { self.resize |= rhs.resize; self.refresh_ui |= rhs.refresh_ui; self.store_modified |= rhs.store_modified; + self.spellcheck_language_modified |= rhs.spellcheck_language_modified; self.view_modified |= rhs.view_modified; self.zoomed_temporarily |= rhs.zoomed_temporarily; self.zoomed |= rhs.zoomed; diff --git a/crates/rnote-ui/data/ui/settingspanel.ui b/crates/rnote-ui/data/ui/settingspanel.ui index e1c205a8e7..4bdf4bc4f8 100644 --- a/crates/rnote-ui/data/ui/settingspanel.ui +++ b/crates/rnote-ui/data/ui/settingspanel.ui @@ -394,6 +394,16 @@ gets disabled. + + + Spellcheck Language + Disable or choose the language for spellchecking + true + + + + + diff --git a/crates/rnote-ui/src/appwindow/mod.rs b/crates/rnote-ui/src/appwindow/mod.rs index 5595f22fd6..85c32bbff6 100644 --- a/crates/rnote-ui/src/appwindow/mod.rs +++ b/crates/rnote-ui/src/appwindow/mod.rs @@ -220,6 +220,10 @@ impl RnAppWindow { canvas.set_unsaved_changes(true); canvas.set_empty(false); } + if widget_flags.spellcheck_language_modified { + canvas.engine_mut().refresh_spellcheck_language(); + canvas.queue_draw(); + } if widget_flags.view_modified { let widget_size = canvas.widget_size(); let offset_mins_maxs = canvas.engine_ref().camera_offset_mins_maxs(); diff --git a/crates/rnote-ui/src/settingspanel/mod.rs b/crates/rnote-ui/src/settingspanel/mod.rs index 282be62719..c409e13bd2 100644 --- a/crates/rnote-ui/src/settingspanel/mod.rs +++ b/crates/rnote-ui/src/settingspanel/mod.rs @@ -14,12 +14,15 @@ use gtk4::{ gdk, glib, glib::clone, subclass::prelude::*, Adjustment, Button, ColorDialogButton, CompositeTemplate, MenuButton, ScrolledWindow, StringList, ToggleButton, Widget, }; +use itertools::Itertools; use num_traits::ToPrimitive; use rnote_compose::penevent::ShortcutKey; use rnote_engine::document::background::PatternStyle; use rnote_engine::document::format::{self, Format, PredefinedFormat}; use rnote_engine::document::Layout; +use rnote_engine::engine::Spellchecker; use rnote_engine::ext::GdkRGBAExt; +use rnote_engine::WidgetFlags; use std::cell::RefCell; mod imp { @@ -30,6 +33,8 @@ mod imp { pub(crate) struct RnSettingsPanel { pub(crate) temporary_format: RefCell, pub(crate) app_restart_toast_singleton: RefCell>, + /// 0 = None, 1.. = available languages + pub(crate) available_spellcheck_languages: RefCell>, #[template_child] pub(crate) settings_scroller: TemplateChild, @@ -94,6 +99,8 @@ mod imp { #[template_child] pub(crate) doc_background_pattern_height_unitentry: TemplateChild, #[template_child] + pub(crate) doc_spellcheck_language_row: TemplateChild, + #[template_child] pub(crate) background_pattern_invert_color_button: TemplateChild