From 0f7a9cacc611827a3ad84fcaee2e91c1b42c4916 Mon Sep 17 00:00:00 2001 From: Kneemund Date: Thu, 31 Oct 2024 02:01:20 +0100 Subject: [PATCH 01/15] feat: initial implementation --- Cargo.lock | 65 +++++++ Cargo.toml | 1 + build-aux/com.github.flxzt.rnote.Devel.json | 15 ++ build-aux/com.github.flxzt.rnote.Devel.yaml | 9 + crates/rnote-engine/Cargo.toml | 1 + crates/rnote-engine/src/engine/mod.rs | 15 ++ .../rnote-engine/src/pens/typewriter/mod.rs | 36 +++- .../src/pens/typewriter/penevents.rs | 56 +++++- crates/rnote-engine/src/strokes/textstroke.rs | 180 ++++++++++++++++-- 9 files changed, 354 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7de72774f..aebd952e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2142,6 +2142,35 @@ dependencies = [ "xml5ever", ] +[[package]] +name = "libspelling" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbd36b794de5725e0b2be4cc90c57c5e3c7a5a3e5c317436e9e667305274c34" +dependencies = [ + "gio", + "glib", + "gtk4", + "libc", + "libspelling-sys", + "sourceview5", +] + +[[package]] +name = "libspelling-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2ec120461981daf9d0c5a8b0bc55ebf350292280e93fd6d063895593754484" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "sourceview5-sys", + "system-deps 7.0.3", +] + [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -3401,6 +3430,7 @@ dependencies = [ "itertools 0.13.0", "kurbo 0.11.1", "librsvg", + "libspelling", "nalgebra", "num-derive", "num-traits", @@ -3776,6 +3806,41 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "sourceview5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.3", +] + [[package]] name = "spade" version = "2.12.1" diff --git a/Cargo.toml b/Cargo.toml index a68f54b600..0d58ed40f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ gio = "0.20.1" glib = "0.20.3" glib-build-tools = "0.20.0" gtk4 = { version = "0.9.1", features = ["v4_14"] } +libspelling = "0.3.0" ijson = "0.1.3" image = "0.25.2" indicatif = "0.17.8" diff --git a/build-aux/com.github.flxzt.rnote.Devel.json b/build-aux/com.github.flxzt.rnote.Devel.json index cf091355f5..f6aa6f0d44 100644 --- a/build-aux/com.github.flxzt.rnote.Devel.json +++ b/build-aux/com.github.flxzt.rnote.Devel.json @@ -82,6 +82,21 @@ } ] }, + { + "name": "libspelling", + "buildsystem": "meson", + "config-opts": [ + "-Dintrospection=disabled", + "-Ddocs=false" + ], + "sources": [ + { + "type": "archive", + "url": "https://download.gnome.org/sources/libspelling/0.4/libspelling-0.4.4.tar.xz", + "sha256": "ac9132c634bb9df911e35b52345aa12126f7ec95e5f07179a980861819787150" + } + ] + }, { "name": "rnote", "buildsystem": "meson", diff --git a/build-aux/com.github.flxzt.rnote.Devel.yaml b/build-aux/com.github.flxzt.rnote.Devel.yaml index 070216443c..1ec34493fa 100644 --- a/build-aux/com.github.flxzt.rnote.Devel.yaml +++ b/build-aux/com.github.flxzt.rnote.Devel.yaml @@ -62,6 +62,15 @@ modules: - type: archive url: "https://poppler.freedesktop.org/poppler-data-0.4.12.tar.gz" sha256: c835b640a40ce357e1b83666aabd95edffa24ddddd49b8daff63adb851cdab74 + - name: libspelling + buildsystem: meson + config-opts: + - "-Dintrospection=disabled" + - "-Ddocs=false" + sources: + - type: archive + url: "https://download.gnome.org/sources/libspelling/0.4/libspelling-0.4.4.tar.xz" + sha256: ac9132c634bb9df911e35b52345aa12126f7ec95e5f07179a980861819787150 - name: rnote buildsystem: meson run-tests: true diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index c8add5331c..64f1128cd5 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -22,6 +22,7 @@ futures = { workspace = true } geo = { workspace = true } gio = { workspace = true } glib = { workspace = true } +libspelling = { workspace = true } ijson = { workspace = true } image = { workspace = true } itertools = { workspace = true } diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index 6a3c661753..4926aae752 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -36,6 +36,11 @@ use std::sync::Arc; use std::time::Instant; use tracing::error; +#[derive(Debug)] +pub struct Spellchecker { + pub libspelling: libspelling::Checker, +} + /// An immutable view into the engine, excluding the penholder. #[derive(Debug)] pub struct EngineView<'a> { @@ -45,6 +50,7 @@ pub struct EngineView<'a> { pub store: &'a StrokeStore, pub camera: &'a Camera, pub audioplayer: &'a Option, + pub spellchecker: &'a Spellchecker, } /// Constructs an `EngineView` from an identifier containing an `Engine` instance. @@ -58,6 +64,7 @@ macro_rules! engine_view { store: &$engine.store, camera: &$engine.camera, audioplayer: &$engine.audioplayer, + spellchecker: &$engine.spellchecker, } }; } @@ -71,6 +78,7 @@ pub struct EngineViewMut<'a> { pub store: &'a mut StrokeStore, pub camera: &'a mut Camera, pub audioplayer: &'a mut Option, + pub spellchecker: &'a mut Spellchecker, } /// Constructs an `EngineViewMut` from an identifier containing an `Engine` instance. @@ -84,6 +92,7 @@ macro_rules! engine_view_mut { store: &mut $engine.store, camera: &mut $engine.camera, audioplayer: &mut $engine.audioplayer, + spellchecker: &mut $engine.spellchecker, } }; } @@ -98,6 +107,7 @@ impl<'a> EngineViewMut<'a> { store: self.store, camera: self.camera, audioplayer: self.audioplayer, + spellchecker: self.spellchecker, } } } @@ -205,6 +215,8 @@ pub struct Engine { #[serde(skip)] audioplayer: Option, #[serde(skip)] + pub spellchecker: Spellchecker, + #[serde(skip)] visual_debug: bool, // the task sender. Must not be modified, only cloned. #[serde(skip)] @@ -241,6 +253,9 @@ impl Default for Engine { optimize_epd: false, audioplayer: None, + spellchecker: Spellchecker { + libspelling: libspelling::Checker::default(), + }, visual_debug: false, tasks_tx: EngineTaskSender(tasks_tx), tasks_rx: Some(EngineTaskReceiver(tasks_rx)), diff --git a/crates/rnote-engine/src/pens/typewriter/mod.rs b/crates/rnote-engine/src/pens/typewriter/mod.rs index f4bd028e7e..3312b2fc65 100644 --- a/crates/rnote-engine/src/pens/typewriter/mod.rs +++ b/crates/rnote-engine/src/pens/typewriter/mod.rs @@ -186,6 +186,29 @@ impl DrawableOnDoc for Typewriter { ); } + // Draw error ranges + for (start_index, length) in &textstroke.error_words { + // TODO: modify underlying functions to take a range/indices instead of cursors + + let start_cursor = + GraphemeCursor::new(*start_index, textstroke.text.len(), true); + + let end_cursor = GraphemeCursor::new( + start_cursor.cur_cursor() + *length, + textstroke.text.len(), + true, + ); + + textstroke.text_style.draw_text_error( + cx, + textstroke.text.clone(), + &start_cursor, + &end_cursor, + &textstroke.transform, + engine_view.camera, + ); + } + // Draw the cursor if self.cursor_visible { textstroke.text_style.draw_cursor( @@ -469,6 +492,7 @@ impl PenBehaviour for Typewriter { cursor, selection_cursor, String::from("").as_str(), + engine_view.spellchecker, ); // Update stroke @@ -641,7 +665,7 @@ 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); + let textstroke = TextStroke::new(text, pos, text_style, engine_view.spellchecker); let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -668,7 +692,7 @@ 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); + let textstroke = TextStroke::new(text, *pos, text_style, engine_view.spellchecker); let cursor = GraphemeCursor::new(text_len, textstroke.text.len(), true); let stroke_key = engine_view @@ -707,6 +731,7 @@ impl Typewriter { cursor, selection_cursor, text.as_str(), + engine_view.spellchecker, ); engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( @@ -733,7 +758,12 @@ impl Typewriter { if let Some(Stroke::TextStroke(textstroke)) = engine_view.store.get_stroke_mut(*stroke_key) { - textstroke.insert_text_after_cursor(text.as_str(), cursor); + textstroke.insert_text_after_cursor( + text.as_str(), + cursor, + engine_view.spellchecker, + ); + engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( *stroke_key, diff --git a/crates/rnote-engine/src/pens/typewriter/penevents.rs b/crates/rnote-engine/src/pens/typewriter/penevents.rs index 3fc6de8b4f..4cad65fd25 100644 --- a/crates/rnote-engine/src/pens/typewriter/penevents.rs +++ b/crates/rnote-engine/src/pens/typewriter/penevents.rs @@ -501,7 +501,14 @@ impl Typewriter { KeyboardKey::Unicode(keychar) => { text_style.ranged_text_attributes.clear(); text_style.set_max_width(Some(text_width)); - let textstroke = TextStroke::new(String::from(keychar), *pos, text_style); + + let textstroke = TextStroke::new( + String::from(keychar), + *pos, + text_style, + engine_view.spellchecker, + ); + let mut cursor = GraphemeCursor::new(0, textstroke.text.len(), true); textstroke.move_cursor_forward(&mut cursor); @@ -596,6 +603,7 @@ impl Typewriter { textstroke.insert_text_after_cursor( keychar.to_string().as_str(), cursor, + engine_view.spellchecker, ); update_stroke(engine_view.store, keychar.is_whitespace()); } @@ -608,9 +616,15 @@ impl Typewriter { } KeyboardKey::BackSpace => { if modifier_keys.contains(&ModifierKey::KeyboardCtrl) { - textstroke.remove_word_before_cursor(cursor); + textstroke.remove_word_before_cursor( + cursor, + engine_view.spellchecker, + ); } else { - textstroke.remove_grapheme_before_cursor(cursor); + textstroke.remove_grapheme_before_cursor( + cursor, + engine_view.spellchecker, + ); } update_stroke(engine_view.store, false); @@ -621,7 +635,11 @@ impl Typewriter { } } KeyboardKey::HorizontalTab => { - textstroke.insert_text_after_cursor("\t", cursor); + textstroke.insert_text_after_cursor( + "\t", + cursor, + engine_view.spellchecker, + ); update_stroke(engine_view.store, false); EventResult { @@ -631,7 +649,11 @@ impl Typewriter { } } KeyboardKey::CarriageReturn | KeyboardKey::Linefeed => { - textstroke.insert_text_after_cursor("\n", cursor); + textstroke.insert_text_after_cursor( + "\n", + cursor, + engine_view.spellchecker, + ); update_stroke(engine_view.store, true); EventResult { @@ -642,9 +664,15 @@ impl Typewriter { } KeyboardKey::Delete => { if modifier_keys.contains(&ModifierKey::KeyboardCtrl) { - textstroke.remove_word_after_cursor(cursor); + textstroke.remove_word_after_cursor( + cursor, + engine_view.spellchecker, + ); } else { - textstroke.remove_grapheme_after_cursor(cursor); + textstroke.remove_grapheme_after_cursor( + cursor, + engine_view.spellchecker, + ); } update_stroke(engine_view.store, false); @@ -856,6 +884,7 @@ impl Typewriter { cursor, selection_cursor, String::from(keychar).as_str(), + engine_view.spellchecker, ); update_stroke(engine_view.store); quit_selecting = true; @@ -961,6 +990,7 @@ impl Typewriter { cursor, selection_cursor, "\n", + engine_view.spellchecker, ); update_stroke(engine_view.store); quit_selecting = true; @@ -975,6 +1005,7 @@ impl Typewriter { cursor, selection_cursor, "", + engine_view.spellchecker, ); update_stroke(engine_view.store); quit_selecting = true; @@ -989,6 +1020,7 @@ impl Typewriter { cursor, selection_cursor, "\t", + engine_view.spellchecker, ); update_stroke(engine_view.store); quit_selecting = true; @@ -1073,7 +1105,7 @@ 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); + let textstroke = TextStroke::new(text, *pos, text_style, engine_view.spellchecker); let cursor = GraphemeCursor::new(text_len, text_len, true); let stroke_key = engine_view @@ -1115,7 +1147,12 @@ impl Typewriter { if let Some(Stroke::TextStroke(ref mut textstroke)) = engine_view.store.get_stroke_mut(*stroke_key) { - textstroke.insert_text_after_cursor(&text, cursor); + textstroke.insert_text_after_cursor( + &text, + cursor, + engine_view.spellchecker, + ); + engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( *stroke_key, @@ -1160,6 +1197,7 @@ impl Typewriter { cursor, selection_cursor, text.as_str(), + engine_view.spellchecker, ); engine_view.store.update_geometry_for_stroke(*stroke_key); engine_view.store.regenerate_rendering_for_stroke( diff --git a/crates/rnote-engine/src/strokes/textstroke.rs b/crates/rnote-engine/src/strokes/textstroke.rs index 83816854d5..5754619b96 100644 --- a/crates/rnote-engine/src/strokes/textstroke.rs +++ b/crates/rnote-engine/src/strokes/textstroke.rs @@ -1,5 +1,6 @@ // Imports use super::Content; +use crate::engine::Spellchecker; use crate::{Camera, Drawable}; use itertools::Itertools; use kurbo::Shape; @@ -10,8 +11,9 @@ use rnote_compose::shapes::Shapeable; use rnote_compose::transform::Transformable; use rnote_compose::{color, Color, Transform}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::ops::Range; -use tracing::error; +use tracing::{debug, error}; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -403,6 +405,40 @@ impl TextStyle { Ok(()) } + pub fn draw_text_error( + &self, + cx: &mut impl piet::RenderContext, + text: String, + cursor: &GraphemeCursor, + selection_cursor: &GraphemeCursor, + transform: &Transform, + camera: &Camera, + ) { + const OUTLINE_COLOR: piet::Color = color::GNOME_REDS[2]; + let outline_width = 1.5 / camera.total_zoom(); + + if let Ok(selection_rects) = + self.get_selection_rects_for_cursors(text, cursor, selection_cursor) + { + for selection_rect in selection_rects { + // FIXME: determine baseline using metrics instead of hardcoding 6.0 + let bottom_line = kurbo::Line::new( + kurbo::Point::new(selection_rect.x0, selection_rect.y1 - 6.0), + kurbo::Point::new(selection_rect.x1, selection_rect.y1 - 6.0), + ); + + let path = transform.to_kurbo() * bottom_line.to_path(0.5); + + cx.stroke_styled( + &path, + &OUTLINE_COLOR, + outline_width, + &piet::StrokeStyle::new().dash_pattern(&[4.0, 2.0]), + ); + } + } + } + pub fn draw_text_selection( &self, cx: &mut impl piet::RenderContext, @@ -441,6 +477,8 @@ pub struct TextStroke { pub transform: Transform, #[serde(rename = "text_style")] pub text_style: TextStyle, + #[serde(skip)] + pub error_words: BTreeMap, } impl Default for TextStroke { @@ -449,6 +487,7 @@ impl Default for TextStroke { text: String::default(), transform: Transform::default(), text_style: TextStyle::default(), + error_words: BTreeMap::new(), } } } @@ -540,12 +579,24 @@ impl Drawable for TextStroke { } impl TextStroke { - pub fn new(text: String, upper_left_pos: na::Vector2, text_style: TextStyle) -> Self { - Self { + 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 { 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 } pub fn get_text_slice_for_range(&self, range: Range) -> &str { @@ -579,16 +630,63 @@ impl TextStroke { )) } - pub fn insert_text_after_cursor(&mut self, text: &str, cursor: &mut GraphemeCursor) { - self.text.insert_str(cursor.cur_cursor(), text); + pub fn check_spelling( + &mut self, + start_index: usize, + end_index: usize, + spellchecker: &Spellchecker, + ) { + let words = self.get_surrounding_words(start_index, end_index); + + for (word_start_index, word) in words { + let valid_word = spellchecker.libspelling.check_word(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()); + } + } + } + + pub fn insert_text_after_cursor( + &mut self, + text: &str, + cursor: &mut GraphemeCursor, + spellchecker: &Spellchecker, + ) { + let cur_pos = cursor.cur_cursor(); + let next_pos = cur_pos + text.len(); + + self.text.insert_str(cur_pos, text); // translate the text attributes - self.translate_attrs_after_cursor(cursor.cur_cursor(), text.len() as i32); + self.translate_attrs_after_cursor(cur_pos, text.len() as i32); - *cursor = GraphemeCursor::new(cursor.cur_cursor() + text.len(), self.text.len(), true); + self.check_spelling(cur_pos, next_pos, spellchecker); + + *cursor = GraphemeCursor::new(next_pos, self.text.len(), true); } - pub fn remove_grapheme_before_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_grapheme_before_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellchecker: &Spellchecker, + ) { if !self.text.is_empty() && self.text.len() >= cursor.cur_cursor() { let cur_pos = cursor.cur_cursor(); @@ -600,6 +698,8 @@ impl TextStroke { prev_pos, prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); + + self.check_spelling(prev_pos, cur_pos, spellchecker); } // New text length, new cursor @@ -607,7 +707,11 @@ impl TextStroke { } } - pub fn remove_grapheme_after_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_grapheme_after_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellchecker: &Spellchecker, + ) { if !self.text.is_empty() && self.text.len() > cursor.cur_cursor() { let cur_pos = cursor.cur_cursor(); @@ -619,6 +723,8 @@ impl TextStroke { cur_pos, -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); + + self.check_spelling(cur_pos, next_pos, spellchecker); } // New text length, new cursor @@ -626,7 +732,11 @@ impl TextStroke { } } - pub fn remove_word_before_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_word_before_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellchecker: &Spellchecker, + ) { let cur_pos = cursor.cur_cursor(); let prev_pos = self.get_prev_word_start_index(cur_pos); @@ -639,12 +749,18 @@ impl TextStroke { prev_pos as i32 - cur_pos as i32 + "".len() as i32, ); + self.check_spelling(prev_pos, cur_pos, spellchecker); + // New text length, new cursor *cursor = GraphemeCursor::new(prev_pos, self.text.len(), true); } } - pub fn remove_word_after_cursor(&mut self, cursor: &mut GraphemeCursor) { + pub fn remove_word_after_cursor( + &mut self, + cursor: &mut GraphemeCursor, + spellchecker: &Spellchecker, + ) { let cur_pos = cursor.cur_cursor(); let next_pos = self.get_next_word_end_index(cur_pos); @@ -657,6 +773,8 @@ impl TextStroke { -(next_pos as i32 - cur_pos as i32) + "".len() as i32, ); + self.check_spelling(cur_pos, next_pos, spellchecker); + // New text length, new cursor *cursor = GraphemeCursor::new(cur_pos, self.text.len(), true); } @@ -667,6 +785,7 @@ impl TextStroke { cursor: &mut GraphemeCursor, selection_cursor: &mut GraphemeCursor, replace_text: &str, + spellchecker: &Spellchecker, ) { let cursor_pos = cursor.cur_cursor(); let selection_cursor_pos = selection_cursor.cur_cursor(); @@ -694,12 +813,33 @@ impl TextStroke { cursor.cur_cursor(), -(cursor_range.end as i32 - cursor_range.start as i32) + replace_text.len() as i32, ); + + self.check_spelling( + cursor_range.start, + cursor_range.start + replace_text.len(), + spellchecker, + ); } /// Translate the ranged text attributes after the given cursor. /// /// Overlapping ranges are extended / shrunk 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) + } else { + self.error_words.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); + } + } + for attr in self.text_style.ranged_text_attributes.iter_mut() { if attr.range.start > from_pos { if offset >= 0 { @@ -830,6 +970,22 @@ impl TextStroke { selection_cursor.set_cursor(0); } + fn get_surrounding_words(&self, start_index: usize, end_index: usize) -> Vec<(usize, String)> { + let mut words = Vec::new(); + + for (word_start, word) in self.text.unicode_word_indices() { + let word_end = word_start + word.len(); + + if word_end >= start_index && word_start <= end_index { + words.push((word_start, word.to_owned())); + } + } + + // debug!("surrounding words: {words:?}"); + + words + } + fn get_prev_word_start_index(&self, current_char_index: usize) -> usize { for (start_index, _) in self.text.unicode_word_indices().rev() { if start_index < current_char_index { From 000e6c8e63f2bde0f8a93888219ba16d3a442afb Mon Sep 17 00:00:00 2001 From: Kneemund Date: Thu, 31 Oct 2024 16:15:54 +0100 Subject: [PATCH 02/15] fix: get baseline from LineMetric --- .../rnote-engine/src/pens/typewriter/mod.rs | 15 +---- crates/rnote-engine/src/strokes/textstroke.rs | 61 +++++++++++-------- 2 files changed, 37 insertions(+), 39 deletions(-) diff --git a/crates/rnote-engine/src/pens/typewriter/mod.rs b/crates/rnote-engine/src/pens/typewriter/mod.rs index 3312b2fc65..6d75b1dd3a 100644 --- a/crates/rnote-engine/src/pens/typewriter/mod.rs +++ b/crates/rnote-engine/src/pens/typewriter/mod.rs @@ -188,22 +188,11 @@ impl DrawableOnDoc for Typewriter { // Draw error ranges for (start_index, length) in &textstroke.error_words { - // TODO: modify underlying functions to take a range/indices instead of cursors - - let start_cursor = - GraphemeCursor::new(*start_index, textstroke.text.len(), true); - - let end_cursor = GraphemeCursor::new( - start_cursor.cur_cursor() + *length, - textstroke.text.len(), - true, - ); - textstroke.text_style.draw_text_error( cx, textstroke.text.clone(), - &start_cursor, - &end_cursor, + *start_index, + *start_index + *length, &textstroke.transform, engine_view.camera, ); diff --git a/crates/rnote-engine/src/strokes/textstroke.rs b/crates/rnote-engine/src/strokes/textstroke.rs index 5754619b96..4caf48f297 100644 --- a/crates/rnote-engine/src/strokes/textstroke.rs +++ b/crates/rnote-engine/src/strokes/textstroke.rs @@ -13,7 +13,7 @@ use rnote_compose::{color, Color, Transform}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::ops::Range; -use tracing::{debug, error}; +use tracing::error; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -339,20 +339,20 @@ impl TextStyle { Ok(text_layout.hit_test_text_position(cursor.cur_cursor())) } - pub fn get_selection_rects_for_cursors( + pub fn get_rects_for_indices( &self, text: String, - cursor: &GraphemeCursor, - selection_cursor: &GraphemeCursor, + start_index: usize, + end_index: usize, ) -> anyhow::Result> { let text_layout = self .build_text_layout(&mut piet_cairo::CairoText::new(), text) .map_err(|e| anyhow::anyhow!("Building text layout failed, Err: {e:?}"))?; - let range = if selection_cursor.cur_cursor() >= cursor.cur_cursor() { - cursor.cur_cursor()..selection_cursor.cur_cursor() + let range = if end_index >= start_index { + start_index..end_index } else { - selection_cursor.cur_cursor()..cursor.cur_cursor() + end_index..start_index }; Ok(text_layout.rects_for_range(range)) @@ -409,8 +409,8 @@ impl TextStyle { &self, cx: &mut impl piet::RenderContext, text: String, - cursor: &GraphemeCursor, - selection_cursor: &GraphemeCursor, + start_index: usize, + end_index: usize, transform: &Transform, camera: &Camera, ) { @@ -418,23 +418,32 @@ impl TextStyle { let outline_width = 1.5 / camera.total_zoom(); if let Ok(selection_rects) = - self.get_selection_rects_for_cursors(text, cursor, selection_cursor) + self.get_rects_for_indices(text.clone(), start_index, end_index) { - for selection_rect in selection_rects { - // FIXME: determine baseline using metrics instead of hardcoding 6.0 - let bottom_line = kurbo::Line::new( - kurbo::Point::new(selection_rect.x0, selection_rect.y1 - 6.0), - kurbo::Point::new(selection_rect.x1, selection_rect.y1 - 6.0), - ); - - let path = transform.to_kurbo() * bottom_line.to_path(0.5); - - cx.stroke_styled( - &path, - &OUTLINE_COLOR, - outline_width, - &piet::StrokeStyle::new().dash_pattern(&[4.0, 2.0]), - ); + // Get baseline for the current line. Really unnecessary to do this for every error since the font size is uniform, + // but piet does not provide any other way to get the baseline. + + if let Ok(line_metric) = self.cursor_line_metric(cx.text(), text, start_index) { + for selection_rect in selection_rects { + let bottom_line = transform.to_kurbo() + * kurbo::Line::new( + kurbo::Point::new( + selection_rect.x0, + selection_rect.y0 + line_metric.baseline + 2.0, + ), + kurbo::Point::new( + selection_rect.x1, + selection_rect.y0 + line_metric.baseline + 2.0, + ), + ); + + cx.stroke_styled( + &bottom_line, + &OUTLINE_COLOR, + outline_width, + &piet::StrokeStyle::new().dash_pattern(&[4.0, 2.0]), + ); + } } } } @@ -453,7 +462,7 @@ impl TextStyle { let outline_width = 1.5 / camera.total_zoom(); if let Ok(selection_rects) = - self.get_selection_rects_for_cursors(text, cursor, selection_cursor) + self.get_rects_for_indices(text, cursor.cur_cursor(), selection_cursor.cur_cursor()) { for selection_rect in selection_rects { let outline = transform.to_kurbo() * selection_rect.to_path(0.5); From ed1a4ac2ddabb387fb94f954a8de623dd6952192 Mon Sep 17 00:00:00 2001 From: Kneemund Date: Thu, 31 Oct 2024 17:26:05 +0100 Subject: [PATCH 03/15] refactor: libspelling -> enchant --- Cargo.lock | 84 +++++-------------- Cargo.toml | 2 +- build-aux/com.github.flxzt.rnote.Devel.json | 15 ---- build-aux/com.github.flxzt.rnote.Devel.yaml | 9 -- crates/rnote-engine/Cargo.toml | 2 +- crates/rnote-engine/src/engine/mod.rs | 36 ++++++-- crates/rnote-engine/src/strokes/textstroke.rs | 50 ++++++----- 7 files changed, 79 insertions(+), 119 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aebd952e63..43fe3008f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,24 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "enchant" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dafd018f9ff5933b0cf9d89a095c31125890f2246952749cbaad230b537644db" +dependencies = [ + "enchant-sys", +] + +[[package]] +name = "enchant-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97af59f7a6d8a217695598f23dc12051734322e60b58e5ca2f3a2fffa59ba34f" +dependencies = [ + "pkg-config", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -2142,35 +2160,6 @@ dependencies = [ "xml5ever", ] -[[package]] -name = "libspelling" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cbd36b794de5725e0b2be4cc90c57c5e3c7a5a3e5c317436e9e667305274c34" -dependencies = [ - "gio", - "glib", - "gtk4", - "libc", - "libspelling-sys", - "sourceview5", -] - -[[package]] -name = "libspelling-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2ec120461981daf9d0c5a8b0bc55ebf350292280e93fd6d063895593754484" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "libc", - "sourceview5-sys", - "system-deps 7.0.3", -] - [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -3419,6 +3408,7 @@ dependencies = [ "cairo-rs", "chrono", "clap", + "enchant", "flate2", "futures", "geo", @@ -3430,7 +3420,6 @@ dependencies = [ "itertools 0.13.0", "kurbo 0.11.1", "librsvg", - "libspelling", "nalgebra", "num-derive", "num-traits", @@ -3806,41 +3795,6 @@ dependencies = [ "futures-lite", ] -[[package]] -name = "sourceview5" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e" -dependencies = [ - "futures-channel", - "futures-core", - "gdk-pixbuf", - "gdk4", - "gio", - "glib", - "gtk4", - "libc", - "pango", - "sourceview5-sys", -] - -[[package]] -name = "sourceview5-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff" -dependencies = [ - "gdk-pixbuf-sys", - "gdk4-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk4-sys", - "libc", - "pango-sys", - "system-deps 7.0.3", -] - [[package]] name = "spade" version = "2.12.1" diff --git a/Cargo.toml b/Cargo.toml index 0d58ed40f0..9ce8a5b57d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,7 @@ gio = "0.20.1" glib = "0.20.3" glib-build-tools = "0.20.0" gtk4 = { version = "0.9.1", features = ["v4_14"] } -libspelling = "0.3.0" +enchant = "0.3.0" ijson = "0.1.3" image = "0.25.2" indicatif = "0.17.8" diff --git a/build-aux/com.github.flxzt.rnote.Devel.json b/build-aux/com.github.flxzt.rnote.Devel.json index f6aa6f0d44..cf091355f5 100644 --- a/build-aux/com.github.flxzt.rnote.Devel.json +++ b/build-aux/com.github.flxzt.rnote.Devel.json @@ -82,21 +82,6 @@ } ] }, - { - "name": "libspelling", - "buildsystem": "meson", - "config-opts": [ - "-Dintrospection=disabled", - "-Ddocs=false" - ], - "sources": [ - { - "type": "archive", - "url": "https://download.gnome.org/sources/libspelling/0.4/libspelling-0.4.4.tar.xz", - "sha256": "ac9132c634bb9df911e35b52345aa12126f7ec95e5f07179a980861819787150" - } - ] - }, { "name": "rnote", "buildsystem": "meson", diff --git a/build-aux/com.github.flxzt.rnote.Devel.yaml b/build-aux/com.github.flxzt.rnote.Devel.yaml index 1ec34493fa..070216443c 100644 --- a/build-aux/com.github.flxzt.rnote.Devel.yaml +++ b/build-aux/com.github.flxzt.rnote.Devel.yaml @@ -62,15 +62,6 @@ modules: - type: archive url: "https://poppler.freedesktop.org/poppler-data-0.4.12.tar.gz" sha256: c835b640a40ce357e1b83666aabd95edffa24ddddd49b8daff63adb851cdab74 - - name: libspelling - buildsystem: meson - config-opts: - - "-Dintrospection=disabled" - - "-Ddocs=false" - sources: - - type: archive - url: "https://download.gnome.org/sources/libspelling/0.4/libspelling-0.4.4.tar.xz" - sha256: ac9132c634bb9df911e35b52345aa12126f7ec95e5f07179a980861819787150 - name: rnote buildsystem: meson run-tests: true diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml index 64f1128cd5..3d7c3bbe69 100644 --- a/crates/rnote-engine/Cargo.toml +++ b/crates/rnote-engine/Cargo.toml @@ -22,7 +22,7 @@ futures = { workspace = true } geo = { workspace = true } gio = { workspace = true } glib = { workspace = true } -libspelling = { workspace = true } +enchant = { workspace = true } ijson = { workspace = true } image = { workspace = true } itertools = { workspace = true } diff --git a/crates/rnote-engine/src/engine/mod.rs b/crates/rnote-engine/src/engine/mod.rs index 4926aae752..28eb35b1b8 100644 --- a/crates/rnote-engine/src/engine/mod.rs +++ b/crates/rnote-engine/src/engine/mod.rs @@ -31,14 +31,42 @@ use rnote_compose::ext::AabbExt; use rnote_compose::penevent::{PenEvent, ShortcutKey}; use rnote_compose::{Color, SplitOrder}; use serde::{Deserialize, Serialize}; +use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use tracing::error; -#[derive(Debug)] pub struct Spellchecker { - pub libspelling: libspelling::Checker, + pub broker: enchant::Broker, + pub dict: Option, +} + +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(), + } + } +} + +impl Debug for Spellchecker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Spellchecker") + .field( + "dict", + &self + .dict + .as_ref() + .map(|dict| format!("Some({})", dict.get_lang())) + .unwrap_or(String::from("None")), + ) + .finish() + } } /// An immutable view into the engine, excluding the penholder. @@ -253,9 +281,7 @@ impl Default for Engine { optimize_epd: false, audioplayer: None, - spellchecker: Spellchecker { - libspelling: libspelling::Checker::default(), - }, + spellchecker: Spellchecker::default(), visual_debug: false, tasks_tx: EngineTaskSender(tasks_tx), tasks_rx: Some(EngineTaskReceiver(tasks_rx)), diff --git a/crates/rnote-engine/src/strokes/textstroke.rs b/crates/rnote-engine/src/strokes/textstroke.rs index 4caf48f297..cbebc8b68f 100644 --- a/crates/rnote-engine/src/strokes/textstroke.rs +++ b/crates/rnote-engine/src/strokes/textstroke.rs @@ -645,29 +645,33 @@ impl TextStroke { end_index: usize, spellchecker: &Spellchecker, ) { - let words = self.get_surrounding_words(start_index, end_index); - - for (word_start_index, word) in words { - let valid_word = spellchecker.libspelling.check_word(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()); + 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}'"); + } } } } From b7f74fa0eadafc9274ce946e38b5a28ea22ba877 Mon Sep 17 00:00:00 2001 From: Kneemund Date: Fri, 1 Nov 2024 23:28:55 +0100 Subject: [PATCH 04/15] 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 6c1a612d54..e7be6c069a 100644 --- a/crates/rnote-engine/src/engine/import.rs +++ b/crates/rnote-engine/src/engine/import.rs @@ -174,6 +174,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 28eb35b1b8..ae19818232 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, } } } @@ -335,6 +348,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 a24a88f145..56dab99a6d 100644 --- a/crates/rnote-ui/src/appwindow/mod.rs +++ b/crates/rnote-ui/src/appwindow/mod.rs @@ -224,6 +224,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