//! Spelling squiggles: misspelt words on the current slide (shapes and table cells) and in the //! speaker notes get a red wavy underline. Checks run on a worker thread so typing never waits for //! a dictionary; the web build has no threads, so there the same checker runs on the UI thread a //! few milliseconds per frame until it is done. Results are for one version of the text; while a //! newer check runs nothing stale is drawn. The word the caret is in isn't marked, as in //! PowerPoint. use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; use deckcraft_engine::DocState; use deckcraft_geom::Point; use deckcraft_model::{Paragraph, ShapeId}; /// Milliseconds of checking per frame on the UI thread (the web build). pub const FRAME_BUDGET_MS: f64 = 4.0; /// Where a checked paragraph is. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Tag { Shape { id: ShapeId, para: usize, }, Cell { id: ShapeId, row: usize, col: usize, para: usize, }, /// A line of the notes, starting at character `start` of the notes text. Notes { start: usize, }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Kind { Slide, Notes, } /// What a check is for: a hash of the text's identity, and the user dictionary version. #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Key { of: u64, user: u64, } struct Job { kind: Kind, key: Key, default_lang: String, paras: Vec<(Tag, Paragraph)>, repaint: Option, } /// Misspelt character ranges, per paragraph. struct Done { kind: Kind, key: Key, ranges: Vec<(Tag, usize, usize)>, } fn now_ms() -> f64 { #[cfg(target_arch = "wasm32")] { js_sys::Date::now() } #[cfg(not(target_arch = "wasm32"))] { static T0: std::sync::OnceLock = std::sync::OnceLock::new(); T0.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 } } /// The checking itself, step by step: the newest job of each kind, a paragraph at a time, with /// results cached by paragraph content (typing re-checks only the paragraph typed in). #[derive(Default)] struct Checker { cache: HashMap>, user: Option, /// Jobs with how far they got and what they found so far. jobs: Vec<(Job, usize, Vec<(Tag, usize, usize)>)>, } fn hash_of(v: &impl Hash) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); v.hash(&mut h); h.finish() } impl Checker { fn push(&mut self, job: Job) { self.jobs.retain(|(j, _, _)| j.kind != job.kind); self.jobs.push((job, 0, vec![])); } fn idle(&self) -> bool { self.jobs.is_empty() } /// Check until `budget_ms` is spent (or everything is done); finished jobs. fn step(&mut self, budget_ms: f64) -> Vec<(Done, Option)> { let t0 = now_ms(); let mut out = vec![]; while let Some((job, at, found)) = self.jobs.first_mut() { if self.user != Some(job.key.user) { self.cache.clear(); self.user = Some(job.key.user); } while *at < job.paras.len() { let (tag, p) = &job.paras[*at]; let k = hash_of(&(serde_json::to_string(&p.runs).unwrap_or_default(), &job.default_lang)); let r = self .cache .entry(k) .or_insert_with(|| deckcraft_engine::spell::misspelt(p, &job.default_lang, &[]).into_iter().map(|w| (w.start, w.end)).collect()); found.extend(r.iter().map(|(a, b)| (*tag, *a, *b))); *at += 1; if now_ms() - t0 > budget_ms { return out; } } let (job, _, ranges) = self.jobs.remove(0); out.push((Done { kind: job.kind, key: job.key, ranges }, job.repaint)); } if self.cache.len() > 20_000 { self.cache.clear(); } out } } fn worker(rx: Receiver, tx: Sender) { let mut c = Checker::default(); loop { if c.idle() { match rx.recv() { Ok(j) => c.push(j), Err(_) => return, } } while let Ok(j) = rx.try_recv() { c.push(j); } // In slices, so a newer job can replace one being checked. for (d, repaint) in c.step(20.0) { if tx.send(d).is_err() { return; } if let Some(r) = repaint { r.request_repaint(); } } } } fn default_lang(st: &DocState) -> String { for sl in &st.doc.slides { let mut found = None; deckcraft_model::walk(&sl.shapes, &mut |sh, _| { if found.is_none() && let Some(t) = &sh.text { found = t.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()); } }); if let Some(l) = found { return l; } } "en-US".into() } /// A word under a squiggle: `(tag, start, end)`. type Mark = (Tag, usize, usize); #[derive(Default)] pub struct Squiggles { tx: Option>, rx: Option>, /// No worker thread (the web build, or forced for tests): check on the UI thread. inline: Option, /// Check on the UI thread even where threads exist (tests of the web path). pub force_inline: bool, pending: [Option; 2], done: [Option; 2], /// Underlines in slide coordinates for the slide's `done`, and the key they were laid out for. lines: Vec<(Mark, Point, Point)>, lines_for: Option, /// Off (tests or the setting). pub disabled: bool, /// The misspelt word a context menu is open on. pub menu_word: Option, /// The same for the notes pane (`Tag::Notes`, `start` in the line). pub notes_menu: Option, /// Words skipped with Ignore All this session (lower-case). ignored: Vec, } /// A misspelt word under the pointer, with corrections. #[derive(Clone, Debug)] pub struct MenuWord { pub tag: Tag, pub start: usize, pub word: String, pub suggestions: Vec, /// A repeated word: Delete Repeated Word removes characters `from..start + word`. pub delete_from: Option, } /// Proofing languages offered by Review › Language: (name, tag). pub const LANGS: &[(&str, &str)] = &[ ("English (United States)", "en-US"), ("English (United Kingdom)", "en-GB"), ("English (Australia)", "en-AU"), ("English (Canada)", "en-CA"), ("Español (España)", "es-ES"), ("Español (México)", "es-MX"), ("Français (France)", "fr-FR"), ("Français (Canada)", "fr-CA"), ("Deutsch (Deutschland)", "de-DE"), ("Deutsch (Schweiz)", "de-CH"), ("Italiano", "it-IT"), ("Nederlands", "nl-NL"), ("Português (Brasil)", "pt-BR"), ("Português (Portugal)", "pt-PT"), ("Polski", "pl-PL"), ("Svenska", "sv-SE"), ("Dansk", "da-DK"), ("Norsk bokmål", "nb-NO"), ("Suomi", "fi-FI"), ("Čeština", "cs-CZ"), ("Українська", "uk-UA"), ("Русский", "ru-RU"), ("Беларуская", "be-BY"), ("Türkçe", "tr-TR"), ("Ελληνικά", "el-GR"), ("日本語", "ja-JP"), ("中文(中国)", "zh-CN"), ("한국어", "ko-KR"), ("العربية", "ar-SA"), ("עברית", "he-IL"), ]; /// The display name of a language tag (the tag itself when it isn't in [`LANGS`]). pub fn lang_name(tag: &str) -> String { LANGS.iter().find(|(_, t)| t.eq_ignore_ascii_case(tag)).map_or_else(|| tag.to_string(), |(n, _)| n.to_string()) } /// The flagged word (misspelt or repeated) of `pp` starting at `start`, with what the menu offers. pub fn menu_word(pp: &Paragraph, default: &str, tag: Tag, start: usize) -> Option { let w = deckcraft_engine::spell::misspelt(pp, default, &[]).into_iter().find(|w| w.start == start)?; let suggestions = if w.repeated.is_some() { vec![] } else { deckcraft_engine::spell::dictionary(&w.lang).map(|d| d.suggest(&w.text, 5)).unwrap_or_default() }; Some(MenuWord { tag, start, word: w.text, suggestions, delete_from: w.repeated }) } fn slot(k: Kind) -> usize { match k { Kind::Slide => 0, Kind::Notes => 1, } } impl Squiggles { fn start(&mut self) -> bool { if self.tx.is_some() || self.inline.is_some() { return true; } if !cfg!(target_arch = "wasm32") && !self.force_inline { let (jtx, jrx) = channel(); let (dtx, drx) = channel(); if std::thread::Builder::new().name("spelling".into()).spawn(move || worker(jrx, dtx)).is_ok() { self.tx = Some(jtx); self.rx = Some(drx); return true; } } self.inline = Some(Checker::default()); true } /// Take in finished checks (and, on the UI thread, spend this frame's budget). fn receive(&mut self, budget_ms: f64) { let mut got = vec![]; if let Some(rx) = &self.rx { while let Ok(d) = rx.try_recv() { got.push(d); } } if let Some(c) = self.inline.as_mut() { got.extend(c.step(budget_ms).into_iter().map(|(d, _)| d)); } for d in got { let i = slot(d.kind); if self.pending[i] == Some(d.key) { self.pending[i] = None; } self.done[i] = Some(d); } } fn ask(&mut self, kind: Kind, key: Key, default_lang: String, paras: impl FnOnce() -> Vec<(Tag, Paragraph)>, repaint: Option<&egui::Context>) { let i = slot(kind); if self.done[i].as_ref().is_some_and(|d| d.key == key) || self.pending[i] == Some(key) { return; } let job = Job { kind, key, default_lang, paras: paras(), repaint: repaint.cloned() }; if let Some(c) = self.inline.as_mut() { c.push(job); self.pending[i] = Some(key); if let Some(r) = repaint { r.request_repaint(); } } else if self.tx.as_ref().is_some_and(|tx| tx.send(job).is_ok()) { self.pending[i] = Some(key); } } fn is_ignored(&self, word: &str) -> bool { !self.ignored.is_empty() && self.ignored.contains(&word.to_lowercase()) } /// Ask for a check of the current slide if it changed, take in finished checks, and return the /// underlines to draw (slide coordinates). pub fn update(&mut self, st: &DocState, repaint: Option<&egui::Context>) -> Vec<(Point, Point)> { if self.disabled || !self.start() { return vec![]; } self.receive(FRAME_BUDGET_MS); let key = Key { of: hash_of(&(Arc::as_ptr(&st.doc) as usize, st.revision, st.selection.slide)), user: deckcraft_engine::spell::user_generation() }; let paras = || { let mut paras = vec![]; if let Some(sl) = st.doc.slides.get(st.selection.slide) { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { if let Some(t) = &sh.text { for (i, p) in t.paragraphs.iter().enumerate() { paras.push((Tag::Shape { id: sh.id, para: i }, p.clone())); } } if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind { for (r, row) in tb.rows.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() { if cell.is_covered() { continue; } for (i, p) in cell.text.paragraphs.iter().enumerate() { paras.push((Tag::Cell { id: sh.id, row: r, col: c, para: i }, p.clone())); } } } } }); } paras }; self.ask(Kind::Slide, key, default_lang(st), paras, repaint); let Some(d) = self.done[0].as_ref().filter(|d| d.key == key) else { return vec![] }; if self.lines_for != Some(key) { self.lines.clear(); for &(tag, a, b) in &d.ranges { let under = match tag { Tag::Shape { id, para } => deckcraft_engine::cmd::text::range_underlines(st, id, &[(para, a, b)]), Tag::Cell { id, row, col, para } => deckcraft_engine::cmd::text::cell_range_underlines(st, id, row, col, &[(para, a, b)]), Tag::Notes { .. } => vec![], }; self.lines.extend(under.into_iter().map(|(p, q)| ((tag, a, b), p, q))); } self.lines_for = Some(key); } // Not under the word the caret is in. let typing = st.selection.text.as_ref().filter(|t| !t.notes).map(|t| (t.shape, t.cell, t.caret)); let under_caret = |tag: &Tag, a: usize, b: usize| { typing.is_some_and(|(s, cell, (cp, cc))| { let here = match *tag { Tag::Shape { id, para } => cell.is_none() && id == s && para == cp, Tag::Cell { id, row, col, para } => cell == Some((row, col)) && id == s && para == cp, Tag::Notes { .. } => false, }; here && cc >= a && cc <= b }) }; self.lines .iter() .filter(|((tag, a, b), _, _)| !under_caret(tag, *a, *b)) .filter(|((tag, a, b), _, _)| self.ignored.is_empty() || !word_of(st, *tag, *a, *b).is_some_and(|w| self.is_ignored(&w))) .map(|(_, a, b)| (*a, *b)) .collect() } /// The misspelt character ranges of the notes text being shown (`caret` is the cursor's /// character index, whose word isn't marked); empty until the check is in. pub fn notes(&mut self, text: &str, lang: &str, caret: Option, repaint: Option<&egui::Context>) -> Vec<(usize, usize)> { if self.disabled || !self.start() { return vec![]; } self.receive(FRAME_BUDGET_MS); let key = Key { of: hash_of(&(text, lang)), user: deckcraft_engine::spell::user_generation() }; let paras = || { let mut out = vec![]; let mut start = 0; for line in text.split('\n') { out.push((Tag::Notes { start }, Paragraph::new(line))); start += line.chars().count() + 1; } out }; self.ask(Kind::Notes, key, lang.to_string(), paras, repaint); let Some(d) = self.done[1].as_ref().filter(|d| d.key == key) else { return vec![] }; let chars: Vec = text.chars().collect(); d.ranges .iter() .filter_map(|&(tag, a, b)| match tag { Tag::Notes { start } => Some((start + a, start + b)), _ => None, }) .filter(|(a, b)| !caret.is_some_and(|c| c >= *a && c <= *b)) .filter(|(a, b)| { self.ignored.is_empty() || !self.is_ignored(&chars.get(*a..*b).map(|c| c.iter().collect::()).unwrap_or_default()) }) .collect() } /// Skip `word` everywhere for the rest of the session. pub fn ignore(&mut self, word: &str) { self.ignored.push(word.to_lowercase()); } /// The squiggled word at slide point `p` (within `tol` points of its underline). pub fn word_at(&self, st: &DocState, p: Point, tol: f64) -> Option { let ((tag, start, end), _, _) = *self.lines.iter().find(|(_, a, b)| { let (x0, x1) = (a.x.min(b.x), a.x.max(b.x)); // From the line's top (a word is about a line high above its underline) to just below it. p.x >= x0 - tol && p.x <= x1 + tol && p.y <= a.y.max(b.y) + tol && p.y >= a.y.min(b.y) - 24.0 })?; let _ = end; menu_word(¶graph_of(st, tag)?, &default_lang(st), tag, start) } /// Wait (tests) until the slide's check for `st` is in. pub fn settle(&mut self, st: &DocState) -> Vec<(Point, Point)> { for _ in 0..2000 { let v = self.update(st, None); if self.pending[0].is_none() && self.done[0].is_some() { return v; } std::thread::sleep(std::time::Duration::from_millis(1)); } vec![] } /// Wait (tests) until the notes check for `text` is in. pub fn settle_notes(&mut self, text: &str, lang: &str) -> Vec<(usize, usize)> { for _ in 0..2000 { let v = self.notes(text, lang, None, None); if self.pending[1].is_none() && self.done[1].is_some() { return v; } std::thread::sleep(std::time::Duration::from_millis(1)); } vec![] } } fn paragraph_of(st: &DocState, tag: Tag) -> Option { let sl = st.doc.slides.get(st.selection.slide)?; match tag { Tag::Shape { id, para } => deckcraft_model::find_shape(&sl.shapes, id)?.text.as_ref()?.paragraphs.get(para).cloned(), Tag::Cell { id, row, col, para } => match &deckcraft_model::find_shape(&sl.shapes, id)?.kind { deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).cloned(), _ => None, }, Tag::Notes { .. } => None, } } fn word_of(st: &DocState, tag: Tag, a: usize, b: usize) -> Option { Some(paragraph_of(st, tag)?.text().chars().skip(a).take(b - a).collect()) } /// A red wavy line from `a` to `b` (screen points). pub fn paint(painter: &egui::Painter, a: egui::Pos2, b: egui::Pos2, scale: f32) { let d = b - a; let len = d.length(); if len < 1.0 { return; } let (u, n) = (d / len, egui::vec2(-d.y, d.x) / len); let step = (2.0 * scale).clamp(1.5, 3.0); let amp = step * 0.75; let mut pts = vec![]; let mut t = 0.0; let mut up = false; while t <= len { pts.push(a + u * t + n * if up { -amp } else { amp }); up = !up; t += step; } painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, egui::Color32::from_rgb(0xE0, 0x20, 0x20)))); }