From b1ce243806b0db6327014b9d8795f2d5dd01a41f Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:47:34 +0300 Subject: [PATCH] Spelling: notes right-click menu, Review > Language with Do Not Check Spelling, repeated words --- crates/engine/src/cmd/format.rs | 27 ++++++++ crates/engine/src/cmd/review.rs | 12 +++- crates/engine/src/spell.rs | 58 +++++++++++++++- crates/engine/src/tests.rs | 21 ++++++ crates/ui-egui/src/canvas.rs | 39 ++++++++++- crates/ui-egui/src/dialogs.rs | 94 ++++++++++++++++++++++---- crates/ui-egui/src/spelling_tests.rs | 98 ++++++++++++++++++++++++++++ crates/ui-egui/src/squiggles.rs | 58 ++++++++++++++-- crates/ui-egui/src/status.rs | 71 +++++++++++++++++++- docs/gaps.md | 9 ++- 10 files changed, 459 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index c68d1242..4e39a2e2 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -48,6 +48,15 @@ pub fn specs() -> Vec { has_text_or_shapes, color ), + cmd!( + "format.language", + "Language", + ["Review", "Language"], + None, + "{lang?, noProof?: bool} (proofing language of the selected text, and Do Not Check Spelling)", + has_text_or_shapes, + language + ), cmd!("format.highlight", "Text Highlight Color", ["Home", "Font"], None, "{color? (absent = none)}", has_text_or_shapes, highlight), cmd!( "format.case", @@ -293,6 +302,24 @@ fn color(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?; format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone()))) } +fn language(s: &mut Session, p: &Value) -> Result { + let lang = str_param(p, "lang").map(str::to_string); + let no_proof = bool_param(p, "noProof"); + if lang.is_none() && no_proof.is_none() { + return Err(bad("format.language", "give `lang` or `noProof`")); + } + if lang.as_deref().is_some_and(|l| l.is_empty() || l.len() > 35 || !l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')) { + return Err(bad("format.language", "`lang` is a language tag such as en-US")); + } + format_run(s, &move |r| { + if let Some(l) = &lang { + r.lang = Some(l.clone()); + } + if let Some(n) = no_proof { + r.no_proof = n.then_some(true); + } + }) +} fn highlight(s: &mut Session, p: &Value) -> Result { let c = color_param(p, "color"); format_run(s, &move |r| r.highlight = c.clone()) diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index d11d19e9..5b038821 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { cmd!("comment.resolve", "Resolve Thread", ["Comments"], None, "{index, resolved?: bool}", has_slide, resolve), cmd!(query "comment.list", "Comments", [], None, "{slide?} → comments", has_slide, list), 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?, 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!(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; repeated words as {repeated, deleteFrom}; runs marked noProof skipped; 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", @@ -236,8 +236,16 @@ fn spelling(s: &mut Session, p: &Value) -> Result { 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::repeated(para, &default) { + let mut v = json!({"start": w.start, "end": w.end, "word": w.text, "lang": w.lang, "repeated": true, "deleteFrom": w.repeated, "suggestions": []}); + if let (Some(o), Some(a)) = (v.as_object_mut(), at.as_object()) { + o.extend(a.clone()); + } + out.push(v); + } + let rep: Vec = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect(); for w in crate::spell::paragraph_words(para, &default) { - if ignore.contains(&w.text.to_lowercase()) { + if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) { continue; } let dict = dicts.entry(w.lang.clone()).or_insert_with(|| crate::spell::dictionary(&w.lang)).clone(); diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs index bf0e1660..965e8578 100644 --- a/crates/engine/src/spell.rs +++ b/crates/engine/src/spell.rs @@ -660,6 +660,9 @@ pub struct Word { pub end: usize, pub text: String, pub lang: String, + /// The same word twice in a row: this is the second, and deleting `cut..end` removes it with + /// the space before it. + pub repeated: Option, } /// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words @@ -700,7 +703,11 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec Vec Vec { + let words = paragraph_words(p, default); + let chars: Vec = p.runs.iter().flat_map(|r| r.content().chars()).collect(); + let mut out = vec![]; + for pair in words.windows(2) { + let (a, b) = (&pair[0], &pair[1]); + let gap = chars.get(a.end..b.start).unwrap_or(&[]); + if !gap.is_empty() + && gap.iter().all(|c| *c == ' ' || *c == '\u{a0}' || *c == '\t') + && a.lang == b.lang + && a.text.to_lowercase() == b.text.to_lowercase() + { + out.push(Word { repeated: Some(a.end), ..b.clone() }); + } + } + out +} + +/// The misspelt and repeated words of `p`, each checked in its own language (languages with no dictionary /// installed are skipped), leaving out `ignore` (lower-case). pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]) -> Vec { let mut dicts: HashMap>> = HashMap::new(); @@ -719,7 +746,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String] let d = dicts.entry(w.lang.clone()).or_insert_with(|| dictionary(&w.lang)); d.as_ref().is_some_and(|d| !is_correct(d, &w.text)) }) - .collect() + .chain(repeated(p, default)) + .collect::>() + .into_iter() + .fold(vec![], |mut v: Vec, w| { + // A repeated misspelt word is reported once, as repeated. + if let Some(i) = v.iter().position(|x| x.start == w.start) { + v[i] = w; + } else { + v.push(w); + } + v + }) } /// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself). @@ -803,6 +841,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n"; ); } + #[test] + fn repeated_words_and_no_proof_runs() { + use deckcraft_model::{Paragraph, Run, RunProps}; + let p = Paragraph::new("See the the plan, plan. The THE end"); + let r: Vec<(usize, usize, Option)> = repeated(&p, "en-US").iter().map(|w| (w.start, w.end, w.repeated)).collect(); + assert_eq!(r, vec![(8, 11, Some(7)), (28, 31, Some(27))]); + let p = Paragraph { + runs: vec![Run::new("check "), Run::with("xyzzy qq", RunProps { no_proof: Some(true), ..Default::default() }), Run::new(" done")], + ..Default::default() + }; + let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect(); + assert_eq!(w, vec!["check", "done"]); + } + #[test] fn long_and_numeric_flags_and_latin1() { let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0e3884ac..df84367a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -909,3 +909,24 @@ fn spelling_checks_each_run_in_its_language_and_learns_words() { assert_eq!(std::fs::read_to_string(dir.join("user.dic")).unwrap(), "deckcraft\n"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn language_marks_text_and_no_proof_and_repeats_are_reported() { + crate::spell::install("xx-LN", crate::spell::Dictionary::parse(b"", b"3\nthe\nend\nis\n")); + let mut s = session(); + let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "the the end is nigh"})).unwrap(); + let id = r["id"].as_u64().unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + s.execute("format.language", &json!({"lang": "xx-LN"})).unwrap(); + let v = s.execute("review.spelling", &json!({})).unwrap(); + let got: Vec<(String, bool)> = v.as_array().unwrap().iter().map(|w| (w["word"].as_str().unwrap().to_string(), w["repeated"] == true)).collect(); + assert_eq!(got, vec![("the".to_string(), true), ("nigh".to_string(), false)]); + assert_eq!(v[0]["deleteFrom"], 3); + // Deleting the repeat through the exact-occurrence change. + s.execute("review.spellingChange", &json!({"shape": id, "para": 0, "start": 3, "word": " the", "to": ""})).unwrap(); + let v = s.execute("shape.inspect", &json!({"id": id})).unwrap(); + assert_eq!(v["shape"]["text"]["paragraphs"][0]["runs"][0]["text"], "the end is nigh"); + s.execute("format.language", &json!({"noProof": true})).unwrap(); + assert!(s.execute("review.spelling", &json!({})).unwrap().as_array().unwrap().is_empty()); + assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a2a4a967..e810fd4b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -638,7 +638,44 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. if let Some(w) = app.squiggles.menu_word.clone() { - if w.suggestions.is_empty() { + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); + // The gap is spaces; take it from the text so tabs or no-break spaces match. + let gap = app + .session + .active() + .and_then(|st| match w.tag { + crate::squiggles::Tag::Shape { id, para } => st.shape(id)?.text.as_ref()?.paragraphs.get(para).map(|p| p.text()), + crate::squiggles::Tag::Cell { id, row, col, para } => match &st.shape(id)?.kind { + deckcraft_model::ShapeKind::Table(tb) => tb.cell(row, col)?.text.paragraphs.get(para).map(|p| p.text()), + _ => None, + }, + crate::squiggles::Tag::Notes { .. } => None, + }) + .map(|t| t.chars().skip(from).take(w.start + w.word.chars().count() - from).collect::()) + .unwrap_or(cut); + let mut p = json!({"para": 0, "start": from, "word": gap, "to": ""}); + if let Some(o) = p.as_object_mut() { + match w.tag { + crate::squiggles::Tag::Shape { id, para } => { + o.insert("shape".into(), json!(id)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Cell { id, row, col, para } => { + o.insert("shape".into(), json!(id)); + o.insert("row".into(), json!(row)); + o.insert("col".into(), json!(col)); + o.insert("para".into(), json!(para)); + } + crate::squiggles::Tag::Notes { .. } => {} + } + } + let _ = app.run("review.spellingChange", p); + app.squiggles.menu_word = None; + ui.close(); + } + } else if w.suggestions.is_empty() { ui.label(egui::RichText::new("(no spelling suggestions)").weak()); } for to in &w.suggestions { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c2380db3..4c87fa6d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -567,6 +567,36 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) .unwrap_or_default(); + if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) + { + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.label(egui::RichText::new("Repeated word").weak()); + if ui.small_button("Delete Repeated Word").clicked() { + let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; + let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; + let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; + let text = app.session.active().and_then(|st| { + let sh = st.shape(deckcraft_model::ShapeId(shape.as_u64()? as u32))?; + let pp = match (item.get("row").and_then(Value::as_u64), item.get("col").and_then(Value::as_u64), &sh.kind) { + (Some(r), Some(c), deckcraft_model::ShapeKind::Table(tb)) => { + tb.cell(r as usize, c as usize)?.text.paragraphs.get(para)?.text() + } + _ => sh.text.as_ref()?.paragraphs.get(para)?.text(), + }; + Some(pp.chars().skip(from).take(end.saturating_sub(from)).collect::()) + }); + if let Some(gap) = text { + let mut p = json!({"shape": shape, "para": para, "start": from, "word": gap, "to": ""}); + if 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); + } + } + }); + } if !sug.is_empty() { ui.horizontal_wrapped(|ui| { ui.add_space(16.0); @@ -653,22 +683,33 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "language" => { + // Starts from the selected text's language and Do Not Check Spelling. + let (cur, np) = selected_language(app); + let lang = d.get("lang", &cur); + let mut no_proof = d.get("noProof", if np { "1" } else { "" }) == "1"; ui.label("Mark selected text as:"); - for l in [ - "English (United States)", - "English (United Kingdom)", - "Español", - "Français", - "Deutsch", - "Italiano", - "Português", - "日本語", - "中文", - "العربية", - ] { - let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + // The selection's own language first when the list doesn't have it. + let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); + let all = own.into_iter().chain(crate::squiggles::LANGS.iter().map(|(n, t)| (n.to_string(), t.to_string()))); + for (name, tag) in all { + let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); + let label = if installed { format!("{name} (dictionary)") } else { name }; + if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { + d.fields.insert("lang".into(), tag); + } + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); + if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); } let (ok, cancel) = buttons(ui, "OK"); + if ok { + let lang = d.get("lang", &cur); + run(app, "format.language", json!({"lang": lang, "noProof": no_proof})); + } ok || cancel } "customShow" => { @@ -928,6 +969,33 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) { )); } +/// The selection's proofing language and whether it's marked Do Not Check Spelling: the run at +/// the caret, else the first run of the first selected shape; US English when none says. +pub fn selected_language(app: &SlideApp) -> (String, bool) { + let Some(st) = app.session.active() else { return ("en-US".into(), false) }; + let run = |sh: &deckcraft_model::Shape, at: Option<(usize, usize)>| -> Option { + let t = sh.text.as_ref()?; + if let Some((p, c)) = at { + let para = t.paragraphs.get(p)?; + let mut n = 0; + for r in ¶.runs { + n += r.char_len(); + if c <= n { + return Some(r.props.clone()); + } + } + return para.runs.last().map(|r| r.props.clone()); + } + t.paragraphs.iter().flat_map(|p| &p.runs).next().map(|r| r.props.clone()) + }; + let props = match &st.selection.text { + Some(t) if !t.notes && t.cell.is_none() => st.shape(t.shape).and_then(|sh| run(sh, Some(t.caret))), + _ => st.selection.shapes.first().and_then(|id| st.shape(*id)).and_then(|sh| run(sh, None)), + }; + let props = props.unwrap_or_default(); + (props.lang.unwrap_or_else(|| "en-US".into()), props.no_proof == Some(true)) +} + #[cfg(test)] #[path = "spelling_tests.rs"] mod spelling_tests; diff --git a/crates/ui-egui/src/spelling_tests.rs b/crates/ui-egui/src/spelling_tests.rs index 725ae508..e88ea23f 100644 --- a/crates/ui-egui/src/spelling_tests.rs +++ b/crates/ui-egui/src/spelling_tests.rs @@ -179,3 +179,101 @@ fn squiggles_in_table_cells_and_notes_and_on_the_ui_thread() { let words: Vec<&str> = v.as_array().unwrap().iter().filter_map(|w| w["word"].as_str()).collect(); assert_eq!(words, vec!["Soth", "Remmeber", "totl"]); } + +fn right_click(h: &mut Harness<'_, SlideApp>, pos: egui::Pos2) { + h.event(egui::Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::default() }); + } + h.run_steps(3); +} + +/// The notes pane's right-click menu corrects a word and deletes a repeated one; the Language +/// dialog marks text and Do Not Check Spelling clears its squiggles. +#[test] +fn notes_menu_language_dialog_and_repeated_words() { + use egui::accesskit::Role; + let aff = "SET UTF-8\nTRY esianrtolcdugmphbyfvkwzxjq\n"; + let dic = "6\nthe\nplan\nremember\nis\ngood\nwork\n"; + deckcraft_engine::spell::install("xx-NM", deckcraft_engine::spell::Dictionary::parse(aff.as_bytes(), dic.as_bytes())); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + app.session.execute("slide.layout", &json!({"layout": "blank"})).ok(); + let id = app + .session + .execute("shape.insert", &json!({"preset": "rect", "rect": [80, 120, 700, 160], "text": "the plan is is good wrok"})) + .unwrap()["id"] + .as_u64() + .unwrap(); + app.session.execute("format.size", &json!({"size": 40})).unwrap(); + app.session.execute("format.language", &json!({"lang": "xx-NM"})).unwrap(); + let lang = deckcraft_model::RunProps { lang: Some("xx-NM".into()), ..Default::default() }; + app.session + .edit(|doc, _| { + std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::TextBody { + paragraphs: vec![deckcraft_model::Paragraph { + runs: vec![deckcraft_model::Run::with("Remmeber the the plan", lang.clone())], + ..Default::default() + }], + ..Default::default() + }; + Ok(()) + }) + .unwrap(); + let st = app.session.active().unwrap().clone(); + // "is is" is flagged as repeated, "wrok" as misspelt. + assert_eq!(app.squiggles.settle(&st).len(), 2); + 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(8); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) { + let _ = img.save(path); + } + // Right-click the notes' first word. + let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); + right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0)); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) { + let _ = img.save(path); + } + assert_eq!(h.state().squiggles.notes_menu.as_ref().map(|w| w.word.as_str()), Some("Remmeber")); + h.get_by_label("Remember").click(); + h.run_steps(3); + let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text(); + assert_eq!(notes_text(&h), "Remember the the plan"); + // The repeated "the": its squiggle starts after "Remember the ". + h.run_steps(4); + let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap(); + let mut found = false; + for dx in (60..160).step_by(6) { + right_click(&mut h, egui::pos2(x.left() + dx as f32, x.top() + 8.0)); + if h.state().squiggles.notes_menu.as_ref().is_some_and(|w| w.delete_from.is_some()) { + found = true; + break; + } + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + } + assert!(found, "no repeated word menu in the notes"); + h.get_by_label("Delete Repeated Word").click(); + h.run_steps(3); + assert_eq!(notes_text(&h), "Remember the plan"); + // Language dialog: Do Not Check Spelling on the shape clears its squiggles. + h.state_mut().session.execute("edit.select", &json!({"ids": [id]})).unwrap(); + h.state_mut().dialog = Some(crate::dialogs::Dialog::new("language")); + h.run_steps(4); + if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_LANG"), h.render()) { + let _ = img.save(path); + } + assert_eq!(crate::dialogs::selected_language(h.state()).0, "xx-NM"); + h.get_by_label("Do not check spelling or grammar").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(crate::dialogs::selected_language(h.state()), ("xx-NM".to_string(), true)); + let st = h.state().session.active().unwrap().clone(); + assert!(h.state_mut().squiggles.settle(&st).is_empty(), "nothing in text marked Do Not Check is flagged, repeats included"); +} diff --git a/crates/ui-egui/src/squiggles.rs b/crates/ui-egui/src/squiggles.rs index ce9f6480..8cd42665 100644 --- a/crates/ui-egui/src/squiggles.rs +++ b/crates/ui-egui/src/squiggles.rs @@ -195,6 +195,8 @@ pub struct Squiggles { 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, } @@ -206,6 +208,55 @@ pub struct MenuWord { 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 { @@ -387,11 +438,8 @@ impl Squiggles { // 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 = 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 suggestions = deckcraft_engine::spell::dictionary(&lang).map(|d| d.suggest(&word, 5)).unwrap_or_default(); - Some(MenuWord { tag, start, word, suggestions }) + let _ = end; + menu_word(¶graph_of(st, tag)?, &default_lang(st), tag, start) } /// Wait (tests) until the slide's check for `st` is in. diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 65ec8bc9..bcc29594 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -20,7 +20,13 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { }; let p = ui.painter(); p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); - p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); + // The selection's proofing language; a click opens Review › Language. + let (lang, no_proof) = crate::dialogs::selected_language(app); + let name = crate::squiggles::lang_name(&lang) + if no_proof { " (not checked)" } else { "" }; + let lr = p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); + if ui.interact(lr.expand(3.0), ui.id().with("status_lang"), Sense::click()).on_hover_text("Language").clicked() { + app.dialog = Some(crate::dialogs::Dialog::new("language")); + } // Transient status message. if let Some((msg, at)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { @@ -171,6 +177,69 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { crate::squiggles::paint(ui.painter(), egui::pos2(ra.left(), y) + o, egui::pos2(rb.left(), y) + o, 1.0); } } + // Right-click on a flagged word: its corrections, Ignore All, Add to Dictionary. + if r.secondary_clicked() + && let Some(pos) = r.interact_pointer_pos() + { + let at = out.galley.cursor_from_pos(pos - out.galley_pos).index.0; + let hit = app.squiggles.notes(&buf, &lang, None, None).into_iter().find(|(a, b)| at >= *a && at <= *b); + app.squiggles.notes_menu = hit.and_then(|(a, _)| { + let before: String = buf.chars().take(a).collect(); + let line_start = before.rfind('\n').map_or(0, |i| before[..i].chars().count() + 1); + let line = buf.chars().skip(line_start).take_while(|c| *c != '\n').collect::(); + crate::squiggles::menu_word( + &deckcraft_model::Paragraph::new(&line), + &lang, + crate::squiggles::Tag::Notes { start: line_start }, + a - line_start, + ) + }); + } + let mut changed: Option = None; + if app.squiggles.notes_menu.is_some() { + r.context_menu(|ui| { + let Some(w) = app.squiggles.notes_menu.clone() else { return }; + let crate::squiggles::Tag::Notes { start: ls } = w.tag else { return }; + let chars: Vec = buf.chars().collect(); + let end = ls + w.start + w.word.chars().count(); + let splice = |from: usize, to: &str| -> String { + chars[..from.min(chars.len())] + .iter() + .chain(to.chars().collect::>().iter()) + .chain(chars[end.min(chars.len())..].iter()) + .collect() + }; + if let Some(from) = w.delete_from { + if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + changed = Some(splice(ls + from, "")); + ui.close(); + } + } else if w.suggestions.is_empty() { + ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + } + for to in &w.suggestions { + if ui.button(egui::RichText::new(to).strong()).clicked() { + changed = Some(splice(ls + w.start, to)); + ui.close(); + } + } + if ui.button("Ignore All").clicked() { + app.squiggles.ignore(&w.word); + ui.close(); + } + if w.delete_from.is_none() && ui.button("Add to Dictionary").clicked() { + if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { + app.set_status(e.to_string()); + } + ui.close(); + } + }); + } + if let Some(new) = changed { + buf = new; + app.squiggles.notes_menu = None; + let _ = app.run("slide.notes", json!({"text": buf, "index": idx})); + } 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 8363d892..325fde0a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -179,12 +179,14 @@ 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; 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 + context menu to correct, ignore or add; table cells and speaker notes are squiggled too, each + with a right-click menu; repeated words (`the the`) are flagged with Delete Repeated Word; + Review › Language marks the selection's proofing language or Do Not Check Spelling + (`lang`/`noProof` round-trip in PPTX). No grammar checking. 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:** 1–3 h (bundle openly licensed dictionaries; a notes context menu). +- **Estimate:** 1–2 h (bundle openly licensed dictionaries). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. @@ -263,6 +265,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words | | 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 |