diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index ce773510..77a585b4 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -34,13 +34,13 @@ pub fn specs() -> Vec { |s, p| neighbour(s, p, false) ), cmd!(query "review.accessibility", "Check Accessibility", ["Review", "Accessibility"], None, "{} → [{slide, shape, issue}]", has_doc, accessibility), - cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape, para, start, end, word, lang, suggestions}] (each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling), + cmd!(query "review.spelling", "Spelling", ["Review", "Proofing"], Some("F7"), "{lang?, ignore?:[word]} → [{slide, shape?, row?, col?, notes?, para, start, end, word, lang, suggestions}] (shapes, table cells and notes; each run in its own language, lang? for runs without one; Hunspell dictionaries installed on the system, else a built-in list of common misspellings)", has_doc, spelling), cmd!( "review.spellingChange", "Change Spelling", ["Review", "Proofing"], None, - "{shape, word, to, all?, para?, start?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)", + "{shape, word, to, all?, para?, start?, row?, col?} → {changed} (replace the whole word in that shape's text, the one at para/start, or in every shape with all)", has_doc, spelling_change ), @@ -306,26 +306,48 @@ fn spelling(s: &mut Session, p: &Value) -> Result { p.get("ignore").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_lowercase).collect()).unwrap_or_default(); let mut dicts: std::collections::HashMap>> = Default::default(); let mut out = vec![]; + // Each misspelt word of `para`, reported with `at` (where the paragraph is) merged in. + let mut check = |para: &deckcraft_model::Paragraph, at: Value, out: &mut Vec| { + for w in crate::spell::paragraph_words(para, &default) { + if ignore.contains(&w.text.to_lowercase()) { + continue; + } + let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); + let bad = match &dict { + Some(d) => !crate::spell::is_correct(d, &w.text), + None => COMMON.contains(&w.text.to_lowercase().as_str()), + }; + if bad { + let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default(); + let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug}); + if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) { + o.extend(a.clone()); + } + out.push(v); + } + } + }; for (i, sl) in st.doc.slides.iter().enumerate() { deckcraft_model::walk(&sl.shapes, &mut |sh, _| { - let Some(t) = &sh.text else { return }; - for (pi, para) in t.paragraphs.iter().enumerate() { - for w in crate::spell::paragraph_words(para, &default) { - if ignore.contains(&w.text.to_lowercase()) { - continue; - } - let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); - let bad = match &dict { - Some(d) => !crate::spell::is_correct(d, &w.text), - None => COMMON.contains(&w.text.to_lowercase().as_str()), - }; - if bad { - let sug = dict.as_ref().map(|d| d.suggest(&w.text, 5)).unwrap_or_default(); - out.push(json!({"slide": i, "shape": sh.id, "para": pi, "start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "suggestions": sug})); + if let Some(t) = &sh.text { + for (pi, para) in t.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "shape": sh.id, "para": pi}), &mut out); + } + } + 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().filter(|(_, c)| !c.is_covered()) { + for (pi, para) in cell.text.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "shape": sh.id, "row": r, "col": c, "para": pi}), &mut out); + } } } } }); + // Speaker notes, a paragraph per line. + for (pi, para) in sl.notes.paragraphs.iter().enumerate() { + check(para, json!({"slide": i, "notes": true, "para": pi}), &mut out); + } } Ok(Value::Array(out)) } @@ -371,11 +393,16 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result { if let (Some(id), Some(para), Some(start)) = (shape, usize_param(p, "para"), usize_param(p, "start")) { let id = deckcraft_model::ShapeId(u32::try_from(id).map_err(|_| bad("review.spellingChange", "bad `shape`"))?); let end = start + word.chars().count(); + let cell = usize_param(p, "row").zip(usize_param(p, "col")); let slide = { let st = s.doc()?; let found = st.doc.slides.iter().position(|sl| { deckcraft_model::find_shape(&sl.shapes, id) - .and_then(|sh| sh.text.as_ref()?.paragraphs.get(para)) + .and_then(|sh| match (cell, &sh.kind) { + (Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell(r, c)?.text.paragraphs.get(para), + (Some(_), _) => None, + (None, _) => sh.text.as_ref()?.paragraphs.get(para), + }) .is_some_and(|pp| pp.text().chars().skip(start).take(end - start).collect::() == word) }); found.ok_or_else(|| bad("review.spellingChange", "the word isn't there"))? @@ -383,7 +410,12 @@ fn spelling_change(s: &mut Session, p: &Value) -> Result { s.edit(|doc, _| { let sl = doc.slides.get_mut(slide).ok_or_else(|| bad("review.spellingChange", "no slide"))?; let sh = deckcraft_model::find_shape_mut(&mut Arc::make_mut(sl).shapes, id).ok_or_else(|| bad("review.spellingChange", "no shape"))?; - if let Some(pp) = sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)) { + let pp = match (cell, &mut sh.kind) { + (Some((r, c)), deckcraft_model::ShapeKind::Table(tb)) => tb.cell_mut(r, c).and_then(|cl| cl.text.paragraphs.get_mut(para)), + (Some(_), _) => None, + (None, _) => sh.text.as_mut().and_then(|t| t.paragraphs.get_mut(para)), + }; + if let Some(pp) = pp { replace_chars(pp, start, end, &to); } Ok(()) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index de8d3691..d4fd26f3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -733,6 +733,40 @@ pub fn range_underlines( out } +/// Like [`range_underlines`], for the text of table `id`'s cell at `row`, `col`. +pub fn cell_range_underlines( + st: &DocState, + id: deckcraft_model::ShapeId, + row: usize, + col: usize, + ranges: &[(usize, usize, usize)], +) -> Vec<(deckcraft_geom::Point, deckcraft_geom::Point)> { + let Some(sh) = st.shape(id) else { return vec![] }; + let deckcraft_model::ShapeKind::Table(tb) = &sh.kind else { return vec![] }; + let (Some(cell), Some(ctx)) = (tb.cell(row, col), ctx_for(&st.doc, &st.selection)) else { return vec![] }; + let heights = crate::cmd::table_drawn_heights(&st.doc, &st.selection, tb); + let Some(rect) = deckcraft_render::table_cell_rects(tb, &heights).into_iter().find(|(r, c, _)| *r == row && *c == col).map(|(_, _, r)| r) else { + return vec![]; + }; + let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell); + let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() }; + let l = deckcraft_text::layout( + &ctx, + &tmp, + &body, + &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false }, + ); + let x = xfrm_of(&st.doc, &st.selection, sh); + let m = deckcraft_render::text_transform(x.affine(), rect, l.rotation); + let mut out = vec![]; + for &(para, a, b) in ranges { + for (x0, x1, y) in l.composition_underlines(deckcraft_text::Pos { para, ch: a }, deckcraft_text::Pos { para, ch: b }) { + out.push((m * deckcraft_geom::Point::new(x0, y), m * deckcraft_geom::Point::new(x1, y))); + } + } + out +} + /// Underlines under an input method's composition (#101), in slide coordinates: `(from, to, /// thick)` per line, thick under the part being converted. Empty without a composition, or for /// notes (an egui text field draws its own composition). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index df7e43c2..1eacf0d7 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -57,6 +57,7 @@ pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts pub use table::cell_body as table_cell_body; pub use table::cell_rects as table_cell_rects; pub use table::row_heights as table_row_heights; +pub use table::{cell_body as table_cell_body, cell_rects as table_cell_rects}; pub use transition::{SlideParts, composite_transition, render_transition}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 847d5a49..9ed8e646 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -790,7 +790,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } for to in &w.suggestions { if ui.button(egui::RichText::new(to).strong()).clicked() { - let _ = app.run("review.spellingChange", json!({"shape": w.shape, "para": w.para, "start": w.start, "word": w.word, "to": to})); + let p = match w.tag { + crate::squiggles::Tag::Shape { id, para } => json!({"shape": id, "para": para, "start": w.start, "word": w.word, "to": to}), + crate::squiggles::Tag::Cell { id, row, col, para } => { + json!({"shape": id, "row": row, "col": col, "para": para, "start": w.start, "word": w.word, "to": to}) + } + crate::squiggles::Tag::Notes { .. } => json!({}), + }; + let _ = app.run("review.spellingChange", p); app.squiggles.menu_word = None; ui.close(); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 361ea833..021d52e6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1755,9 +1755,44 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); if r.clicked() { let all = ui.input(|i| i.modifiers.shift); - let p = - if all { json!({"word": w, "to": to, "all": true}) } else { json!({"shape": shape, "word": w, "to": to}) }; - run(app, "review.spellingChange", p); + if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { + // A word in the notes: change that line of the slide's notes. + let si = slide.as_u64().unwrap_or(0) as usize; + let notes = + app.session.active().and_then(|d| d.doc.slides.get(si)).map(|s| s.notes_text()).unwrap_or_default(); + let (pi, st) = ( + item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize, + item.get("start").and_then(Value::as_u64).unwrap_or(0) as usize, + ); + let lines: Vec = notes + .split('\n') + .enumerate() + .map(|(i, l)| { + if i != pi { + return l.to_string(); + } + let c: Vec = l.chars().collect(); + let n = w.chars().count(); + if c.get(st..st + n).is_some_and(|x| x.iter().collect::() == w) { + c[..st].iter().chain(to.chars().collect::>().iter()).chain(c[st + n..].iter()).collect() + } else { + l.to_string() + } + }) + .collect(); + run(app, "slide.notes", json!({"text": lines.join("\n"), "index": si})); + } else { + let mut p = if all { + json!({"word": w, "to": to, "all": true}) + } else { + json!({"shape": shape, "word": w, "to": to, "para": item.get("para"), "start": item.get("start")}) + }; + if !all && let (Some(o), Some(r), Some(c)) = (p.as_object_mut(), item.get("row"), item.get("col")) { + o.insert("row".into(), r.clone()); + o.insert("col".into(), c.clone()); + } + run(app, "review.spellingChange", p); + } } } }); diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs index 7d9b5537..725ae508 100644 --- a/crates/ui-egui/src/spelling_tests.rs +++ b/crates/ui-egui/src/spelling_tests.rs @@ -97,10 +97,7 @@ fn squiggles_mark_misspelt_words_in_the_background() { let w = h.state().squiggles.word_at(&st, deckcraft_geom::Point::new(a.x + 5.0, a.y - 5.0), 2.0).unwrap(); assert_eq!(w.word, "plna"); assert_eq!(w.suggestions.first().map(String::as_str), Some("plan")); - h.state_mut() - .session - .execute("review.spellingChange", &json!({"shape": id, "para": w.para, "start": w.start, "word": w.word, "to": "plan"})) - .unwrap(); + h.state_mut().session.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": w.start, "word": w.word, "to": "plan"})).unwrap(); h.state_mut().session.execute("review.addToDictionary", &json!({"word": "reveiw"})).unwrap(); let st = h.state().session.active().unwrap().clone(); assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1); @@ -113,3 +110,72 @@ fn squiggles_mark_misspelt_words_in_the_background() { assert_eq!(h.state_mut().squiggles.settle(&st).len(), 0, "the word being typed isn't marked"); let _ = std::fs::remove_dir_all(&dir); } + +/// Table cells and speaker notes are squiggled too, and the UI-thread checker (the web build's) +/// gets there a few milliseconds per frame. +#[test] +fn squiggles_in_table_cells_and_notes_and_on_the_ui_thread() { + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\nSFX S Y 1\nSFX S 0 s .\n"; + let dic = "10\nthe/S\nplan/S\nsales\nregion/S\ntotal/S\nnorth\nsouth\nremember\nto\nshow\n"; + deckcraft_engine::spell::install("xx-TB", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.squiggles.force_inline = true; + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app.session.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [80, 80, 600, 160]})).unwrap()["id"].as_u64().unwrap(); + let sid = deckcraft_model::ShapeId(id as u32); + let lang = deckcraft_model::RunProps { lang: Some("xx-TB".into()), size: Some(28.0), ..Default::default() }; + app.session + .edit(|doc, _| { + let sl = std::sync::Arc::make_mut(&mut doc.slides[0]); + sl.notes = deckcraft_model::TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![deckcraft_model::Run::with("Remmeber to show the totl", lang.clone())], + ..Default::default() + }], + ..Default::default() + }; + let sh = deckcraft_model::find_shape_mut(&mut sl.shapes, sid).unwrap(); + let deckcraft_model::ShapeKind::Table(tb) = &mut sh.kind else { panic!("not a table") }; + for (r, c, t) in [(0, 0, "Regoin"), (0, 1, "Sales"), (1, 0, "North"), (1, 1, "Soth totals")] { + tb.cell_mut(r, c).unwrap().text.paragraphs = + vec![deckcraft_model::Paragraph { runs: vec![deckcraft_model::Run::with(t, lang.clone())], ..Default::default() }]; + } + Ok(()) + }) + .unwrap(); + app.session.execute("edit.select", &json!({"ids": []})).unwrap(); + let st = app.session.active().unwrap().clone(); + let lines = app.squiggles.settle(&st); + assert_eq!(lines.len(), 2, "Regoin and Soth: {lines:?}"); + // Each under its own cell: the first in the top-left one, the second in the bottom-right one. + assert!(lines[0].0.x < 380.0 && lines[0].0.y < 160.0 && lines[1].0.x > 380.0 && lines[1].0.y > 160.0, "{lines:?}"); + assert_eq!(app.squiggles.settle_notes("Remmeber to show the totl", "xx-TB"), vec![(0, 8), (21, 25)]); + let mut h = Harness::builder().with_size(egui::vec2(1100.0, 760.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_TABLE"), h.render()) { + let _ = img.save(path); + } + // Correcting a cell's word through the context menu's command (settled again: frames may + // have moved the document on). + let st = h.state().session.active().unwrap().clone(); + let lines = h.state_mut().squiggles.settle(&st); + let (a, _) = lines[0]; + let w = h.state().squiggles.word_at(&st, deckcraft_geom::Point::new(a.x + 3.0, a.y - 5.0), 2.0).unwrap(); + assert_eq!((w.word.as_str(), w.suggestions.first().map(String::as_str)), ("Regoin", Some("Region"))); + h.state_mut() + .session + .execute("review.spellingChange", &json!({"shape": id, "row": 0, "col": 0, "para": 0, "start": w.start, "word": w.word, "to": "Region"})) + .unwrap(); + let st = h.state().session.active().unwrap().clone(); + assert_eq!(h.state_mut().squiggles.settle(&st).len(), 1); + // The Spelling dialog's list has the cell and the notes too. + let v = h.state_mut().session.execute("review.spelling", &json!({})).unwrap(); + let words: Vec<&str> = v.as_array().unwrap().iter().filter_map(|w| w["word"].as_str()).collect(); + assert_eq!(words, vec!["Soth", "Remmeber", "totl"]); +} diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs index 17e55fdf..ce9f6480 100644 --- a/crates/ui-egui/src/squiggles.rs +++ b/crates/ui-egui/src/squiggles.rs @@ -1,9 +1,12 @@ -//! Spelling squiggles: misspelt words on the current slide get a red wavy underline, checked on a -//! worker thread so typing never waits for a dictionary. Results are for one version of the -//! document; while a newer check runs, nothing stale is drawn. The word being typed (the caret at -//! or inside it) isn't marked, as in PowerPoint. The web build has no worker and shows none. +//! 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}; @@ -11,85 +14,146 @@ use deckcraft_engine::DocState; use deckcraft_geom::Point; use deckcraft_model::{Paragraph, ShapeId}; -/// What a check is for: the document version, the slide and the user dictionary version. +/// 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)] -pub struct Key { - doc: usize, - rev: u64, - slide: usize, +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<(ShapeId, usize, Paragraph)>, + paras: Vec<(Tag, Paragraph)>, repaint: Option, } -/// Misspelt ranges for one key: `(shape, para, start, end)`. +/// Misspelt character ranges, per paragraph. struct Done { + kind: Kind, key: Key, - ranges: Vec<(ShapeId, usize, usize, usize)>, + 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)] -pub struct Squiggles { - tx: Option>, - rx: Option>, - /// The check in flight, if any. - pending: Option, - done: Option, - /// Underlines in slide coordinates for `done`, with each one's word: `(shape, para, start, end)`. - lines: Vec<((ShapeId, usize, usize, usize), Point, Point)>, - /// Off (tests or the setting). - pub disabled: bool, - /// The misspelt word a context menu is open on. - pub menu_word: Option, - /// Words skipped with Ignore All this session (lower-case). - ignored: Vec, +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)>)>, } -/// A misspelt word under the pointer, with corrections. -#[derive(Clone, Debug)] -pub struct MenuWord { - pub shape: ShapeId, - pub para: usize, - pub start: usize, - pub word: String, - pub suggestions: Vec, +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) { - // Paragraph results by content, so typing in one paragraph re-checks only that one. - let mut cache: HashMap<(String, u64), Vec<(usize, usize)>> = HashMap::new(); - let mut user = None; - while let Ok(mut job) = rx.recv() { - // Only the newest job matters. + 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() { - job = j; + c.push(j); } - if user != Some(job.key.user) { - cache.clear(); - user = Some(job.key.user); - } - let mut ranges = vec![]; - for (id, pi, p) in &job.paras { - let k = (serde_json::to_string(&p.runs).unwrap_or_default(), job.key.user); - let k = (format!("{}\u{0}{}", job.default_lang, k.0), k.1); - let r = cache - .entry(k) - .or_insert_with(|| deckcraft_engine::spell::misspelt(p, &job.default_lang, &[]).into_iter().map(|w| (w.start, w.end)).collect()); - ranges.extend(r.iter().map(|(a, b)| (*id, *pi, *a, *b))); - } - if cache.len() > 20_000 { - cache.clear(); - } - if tx.send(Done { key: job.key, ranges }).is_err() { - return; - } - if let Some(c) = &job.repaint { - c.request_repaint(); + // 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(); + } } } } @@ -111,22 +175,103 @@ fn default_lang(st: &DocState) -> String { "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, + /// 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, +} + +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() { + if self.tx.is_some() || self.inline.is_some() { return true; } - if cfg!(target_arch = "wasm32") { - return false; + 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; + } } - let (jtx, jrx) = channel(); - let (dtx, drx) = channel(); - let ok = std::thread::Builder::new().name("spelling".into()).spawn(move || worker(jrx, dtx)).is_ok(); - if ok { - self.tx = Some(jtx); - self.rx = Some(drx); + 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); + } } - ok + 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 @@ -135,69 +280,101 @@ impl Squiggles { if self.disabled || !self.start() { return vec![]; } + self.receive(FRAME_BUDGET_MS); let key = - Key { doc: Arc::as_ptr(&st.doc) as usize, rev: st.revision, slide: st.selection.slide, user: deckcraft_engine::spell::user_generation() }; - if let Some(rx) = &self.rx { - while let Ok(d) = rx.try_recv() { - if self.pending == Some(d.key) { - self.pending = None; - } - self.lines.clear(); - self.done = Some(d); - if let Some(d) = &self.done - && d.key == key - { - let mut by_shape: HashMap> = HashMap::new(); - for (id, p, a, b) in &d.ranges { - by_shape.entry(*id).or_default().push((*p, *a, *b)); - } - for (id, rs) in by_shape { - for r in rs { - for (a, b) in deckcraft_engine::cmd::text::range_underlines(st, id, &[r]) { - self.lines.push(((id, r.0, r.1, r.2), a, b)); - } - } - } - } - } - } - let fresh = self.done.as_ref().is_some_and(|d| d.key == key); - if !fresh && self.pending != Some(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((sh.id, i, p.clone())); + 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())); + } + } } } }); } - let job = Job { key, default_lang: default_lang(st), paras, repaint: repaint.cloned() }; - if self.tx.as_ref().is_some_and(|tx| tx.send(job).is_ok()) { - self.pending = Some(key); + 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))); } - } - if !fresh { - return vec![]; + self.lines_for = Some(key); } // Not under the word the caret is in. - let typing = st.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| (t.shape, t.caret)); + 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(|((id, p, a, b), _, _)| !typing.is_some_and(|(s, (cp, cc))| s == *id && cp == *p && cc >= *a && cc <= *b)) - .filter(|((id, p, a, b), _, _)| { - self.ignored.is_empty() || { - let w = st - .shape(*id) - .and_then(|sh| sh.text.as_ref()?.paragraphs.get(*p).map(|pp| pp.text().chars().skip(*a).take(b - a).collect::())); - !w.is_some_and(|w| self.ignored.contains(&w.to_lowercase())) - } - }) + .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()); @@ -205,37 +382,57 @@ impl Squiggles { /// 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 ((shape, para, start, end), _, _) = *self.lines.iter().find(|(_, a, b)| { + 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 pp = st - .doc - .slides - .get(st.selection.slide) - .and_then(|sl| deckcraft_model::find_shape(&sl.shapes, shape))? - .text - .as_ref()? - .paragraphs - .get(para)?; + let pp = paragraph_of(st, tag)?; let word: String = pp.text().chars().skip(start).take(end - start).collect(); - let lang = deckcraft_engine::spell::paragraph_words(pp, &default_lang(st)).into_iter().find(|w| w.start == start).map(|w| w.lang)?; + let lang = deckcraft_engine::spell::paragraph_words(&pp, &default_lang(st)).into_iter().find(|w| w.start == start).map(|w| w.lang)?; let suggestions = deckcraft_engine::spell::dictionary(&lang).map(|d| d.suggest(&word, 5)).unwrap_or_default(); - Some(MenuWord { shape, para, start, word, suggestions }) + Some(MenuWord { tag, start, word, suggestions }) } - /// Wait (tests) until the check for the current state is in. + /// Wait (tests) until the slide's check for `st` is in. pub fn settle(&mut self, st: &DocState) -> Vec<(Point, Point)> { - for _ in 0..500 { + for _ in 0..2000 { let v = self.update(st, None); - if self.done.as_ref().is_some_and(|d| self.pending.is_none() && d.key.doc == Arc::as_ptr(&st.doc) as usize) { + if self.pending[0].is_none() && self.done[0].is_some() { return v; } - std::thread::sleep(std::time::Duration::from_millis(2)); + 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). diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 94055d46..14b18afd 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -109,6 +109,11 @@ fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { } /// The speaker notes editor under the slide. +/// The language of a slide's notes: the first notes run that has one. +fn notes_lang(s: &deckcraft_model::Slide) -> Option { + s.notes.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone()) +} + pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; @@ -146,21 +151,33 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { } let mut buf = app.notes_buf.2.clone(); // Long notes scroll inside the pane instead of spilling over the slide (#221). - let r = egui::ScrollArea::vertical() + let out = egui::ScrollArea::vertical() .max_height(ui.available_height()) .auto_shrink([false, false]) .show(ui, |ui| { - ui.add( - egui::TextEdit::multiline(&mut buf) - .id(id) - .hint_text("Click to add notes") - .frame(egui::Frame::NONE) - .desired_width(f32::INFINITY) - .desired_rows(2) - .font(theme::font(13.0)), - ) + egui::TextEdit::multiline(&mut buf) + .id(id) + .hint_text("Click to add notes") + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .desired_rows(2) + .font(theme::font(13.0)) + .show(ui) }) .inner; + let r = out.response.clone(); + // Spelling squiggles under misspelt words of the notes. + let lang = app.session.active().and_then(|d| d.doc.slides.get(idx)).and_then(|s| notes_lang(s)).unwrap_or_else(|| "en-US".into()); + let caret = out.cursor_range.map(|c| c.primary.index.0); + for (a, b) in app.squiggles.notes(&buf, &lang, caret, Some(ui.ctx())) { + let (ra, rb) = (out.galley.pos_from_cursor(egui::text::CCursor::new(a)), out.galley.pos_from_cursor(egui::text::CCursor::new(b))); + // One underline per row the word is on (a word doesn't wrap, so normally one). + if (ra.bottom() - rb.bottom()).abs() < 1.0 { + let y = ra.bottom() - 1.0; + let o = out.galley_pos.to_vec2(); + crate::squiggles::paint(ui.painter(), egui::pos2(ra.left(), y) + o, egui::pos2(rb.left(), y) + o, 1.0); + } + } app.notes_buf.2 = buf.clone(); if r.lost_focus() && buf != current { let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); diff --git a/docs/gaps.md b/docs/gaps.md index 30e91a5d..27a40b3e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -312,11 +312,12 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * (`crates/engine/src/spell.rs`: affixes, cross products, one level of continuation, compounds, case rules, suggestions), each run in its own language (`lang` from PPTX), with a user dictionary for Add to Dictionary. Red squiggles on the current slide come from a worker thread, with a - context menu to correct, ignore or add. None ship with DeckCraft, so without one installed (always - on Windows and the web) only a short list of common misspellings is found, and the web build has - no squiggles. Notes and table cells aren't squiggled. + context menu to correct, ignore or add; table cells and speaker notes are squiggled too (the + notes have no context menu yet), and the web build checks on the UI thread a few milliseconds per + frame. None ship with DeckCraft, so without one installed (always on Windows and the web) only a + short list of common misspellings is found. Thesaurus (#549) and AutoCorrect (#521) have open PRs. -- **Estimate:** 2–4 h (bundle openly licensed dictionaries; squiggles in notes and table cells). +- **Estimate:** 1–3 h (bundle openly licensed dictionaries; a notes context menu). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. @@ -459,6 +460,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 | | 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 | | 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | +| 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web | | 2026-10-14 | minor | Spelling squiggles while typing, Add to Dictionary, per-run languages and compounds | | 2026-10-14 | minor | Spelling checks with installed Hunspell dictionaries and suggests corrections | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |